Как программно прокручивать список в SwiftUI?

Похоже, что в текущих инструментах / системе, только что выпущенных Xcode 11.4 / iOS 13.4, не будет встроенной поддержки SwiftUI для функции "прокрутки" в List. Так что даже если они, Apple, представят его в следующем крупном выпуске, мне понадобится обратная поддержка iOS 13.x.

Итак, как бы мне сделать это самым простым и легким способом?

  • прокрутите список до конца
  • прокрутите список вверх
  • и другие

(Я не люблю упаковывать полностью UITableView инфраструктура в UIViewRepresentable/UIViewControllerRepresentable как было предложено ранее на SO).

11 ответов

Решение

SWIFTUI 2.0

Вот возможное альтернативное решение в Xcode 12 / iOS 14 (SwiftUI 2.0), которое можно использовать в том же сценарии, когда элементы управления для прокрутки находятся за пределами области прокрутки (поскольку SwiftUI2 ScrollViewReaderможно использовать только внутри ScrollView)

Примечание. Дизайн содержимого строки не рассматривается

Протестировано с Xcode 12b / iOS 14

class ScrollToModel: ObservableObject {
    enum Action {
        case end
        case top
    }
    @Published var direction: Action? = nil
}

struct ContentView: View {
    @StateObject var vm = ScrollToModel()

    let items = (0..<200).map { $0 }
    var body: some View {
        VStack {
            HStack {
                Button(action: { vm.direction = .top }) { // < here
                    Image(systemName: "arrow.up.to.line")
                      .padding(.horizontal)
                }
                Button(action: { vm.direction = .end }) { // << here
                    Image(systemName: "arrow.down.to.line")
                      .padding(.horizontal)
                }
            }
            Divider()
            
            ScrollView {
                ScrollViewReader { sp in
                    LazyVStack {
                        ForEach(items, id: \.self) { item in
                            VStack(alignment: .leading) {
                                Text("Item \(item)").id(item)
                                Divider()
                            }.frame(maxWidth: .infinity).padding(.horizontal)
                        }
                    }.onReceive(vm.$direction) { action in
                        guard !items.isEmpty else { return }
                        withAnimation {
                            switch action {
                                case .top:
                                    sp.scrollTo(items.first!, anchor: .top)
                                case .end:
                                    sp.scrollTo(items.last!, anchor: .bottom)
                                default:
                                    return
                            }
                        }
                    }
                }
            }
        }
    }
}

SWIFTUI 1.0+

Вот упрощенный вариант подхода, который работает, выглядит уместно и занимает пару экранов кода.

Протестировано с Xcode 11.2+ / iOS 13.2+ (также с Xcode 12b / iOS 14)

Демо использования:

struct ContentView: View {
    private let scrollingProxy = ListScrollingProxy() // proxy helper

    var body: some View {
        VStack {
            HStack {
                Button(action: { self.scrollingProxy.scrollTo(.top) }) { // < here
                    Image(systemName: "arrow.up.to.line")
                      .padding(.horizontal)
                }
                Button(action: { self.scrollingProxy.scrollTo(.end) }) { // << here
                    Image(systemName: "arrow.down.to.line")
                      .padding(.horizontal)
                }
            }
            Divider()
            List {
                ForEach(0 ..< 200) { i in
                    Text("Item \(i)")
                        .background(
                           ListScrollingHelper(proxy: self.scrollingProxy) // injection
                        )
                }
            }
        }
    }
}

демо

Решение:

Легкий вид, представимый, вводится в Listдает доступ к иерархии представлений UIKit. В качествеList повторно использует строки, в которых больше нет значений, а затем помещает строки на экран.

struct ListScrollingHelper: UIViewRepresentable {
    let proxy: ListScrollingProxy // reference type

    func makeUIView(context: Context) -> UIView {
        return UIView() // managed by SwiftUI, no overloads
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        proxy.catchScrollView(for: uiView) // here UIView is in view hierarchy
    }
}

Простой прокси, который находит вложенные UIScrollView (необходимо сделать один раз), а затем перенаправляет необходимые действия с прокруткой к этому сохраненному просмотру прокрутки

class ListScrollingProxy {
    enum Action {
        case end
        case top
        case point(point: CGPoint)     // << bonus !!
    }

    private var scrollView: UIScrollView?

    func catchScrollView(for view: UIView) {
        if nil == scrollView {
            scrollView = view.enclosingScrollView()
        }
    }

    func scrollTo(_ action: Action) {
        if let scroller = scrollView {
            var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
            switch action {
                case .end:
                    rect.origin.y = scroller.contentSize.height +
                        scroller.contentInset.bottom + scroller.contentInset.top - 1
                case .point(let point):
                    rect.origin.y = point.y
                default: {
                    // default goes to top
                }()
            }
            scroller.scrollRectToVisible(rect, animated: true)
        }
    }
}

extension UIView {
    func enclosingScrollView() -> UIScrollView? {
        var next: UIView? = self
        repeat {
            next = next?.superview
            if let scrollview = next as? UIScrollView {
                return scrollview
            }
        } while next != nil
        return nil
    }
}

Поскольку SwiftUI структурирован, управляемый данными, вы должны знать все идентификаторы своих элементов. Таким образом, вы можете перейти к любому идентификатору с помощьюScrollViewReaderиз iOS 14 и с Xcode 12

struct ContentView: View {
    let items = (1...100)

    var body: some View {
        ScrollViewReader { scrollProxy in
            ScrollView {
                ForEach(items, id: \.self) { Text("\($0)"); Divider() }
            }

            HStack {
                Button("First!") { withAnimation { scrollProxy.scrollTo(items.first!) } }
                Button("Any!") { withAnimation { scrollProxy.scrollTo(50) } }
                Button("Last!") { withAnimation { scrollProxy.scrollTo(items.last!) } }
            }
        }
    }
}

Обратите внимание, что ScrollViewReaderдолжен поддерживать весь прокручиваемый контент, но теперь поддерживает толькоScrollView


Предварительный просмотр

Вот простое решение, которое работает на iOS13 и 14: В моем случае была начальная позиция прокрутки.

ScrollView(.vertical, showsIndicators: false, content: {
        ...
    })
    .introspectScrollView(customize: { scrollView in
        scrollView.scrollRectToVisible(CGRect(x: 0, y: offset, width: 100, height: 300), animated: false)
    })

При необходимости высоту можно рассчитать, исходя из размера экрана или самого элемента. Это решение для вертикальной прокрутки. Для горизонтального вы должны указать x и оставить y равным 0

Спасибо, Аспери, отличный совет. Мне нужно, чтобы список прокручивался вверх, когда новые записи добавлялись за пределами представления. Переработан под macOS.

Я взял переменную состояния / прокси в объект среды и использовал ее вне представления для принудительной прокрутки. Я обнаружил, что мне пришлось обновить его дважды, второй раз с задержкой 0,5 секунды, чтобы получить лучший результат. Первое обновление предотвращает прокрутку представления назад к началу при добавлении строки. Второе обновление прокручивается до последней строки. Я новичок и это мой первый пост о stackru:o

Обновлено для MacOS:

struct ListScrollingHelper: NSViewRepresentable {

    let proxy: ListScrollingProxy // reference type

    func makeNSView(context: Context) -> NSView {
        return NSView() // managed by SwiftUI, no overloads
    }

    func updateNSView(_ nsView: NSView, context: Context) {
        proxy.catchScrollView(for: nsView) // here NSView is in view hierarchy
    }
}

class ListScrollingProxy {
    //updated for mac osx
    enum Action {
        case end
        case top
        case point(point: CGPoint)     // << bonus !!
    }

    private var scrollView: NSScrollView?

    func catchScrollView(for view: NSView) {
        //if nil == scrollView { //unB - seems to lose original view when list is emptied
            scrollView = view.enclosingScrollView()
        //}
    }

    func scrollTo(_ action: Action) {
        if let scroller = scrollView {
            var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
            switch action {
                case .end:
                    rect.origin.y = scroller.contentView.frame.minY
                    if let documentHeight = scroller.documentView?.frame.height {
                        rect.origin.y = documentHeight - scroller.contentSize.height
                    }
                case .point(let point):
                    rect.origin.y = point.y
                default: {
                    // default goes to top
                }()
            }
            //tried animations without success :(
            scroller.contentView.scroll(to: NSPoint(x: rect.minX, y: rect.minY))
            scroller.reflectScrolledClipView(scroller.contentView)
        }
    }
}
extension NSView {
    func enclosingScrollView() -> NSScrollView? {
        var next: NSView? = self
        repeat {
            next = next?.superview
            if let scrollview = next as? NSScrollView {
                return scrollview
            }
        } while next != nil
        return nil
    }
}

мои два цента за удаление и перемещение списка в любой момент на основе другой логики .. например, после удаления, пример идет наверх.(это сверхуменьшенный образец, я использую после сетевого вызова для изменения положения: после сетевого вызова я меняю previousIndex)

struct ContentView: View {

      @State private var previousIndex : Int? = nil
@State private var items = Array(0...100)

func removeRows(at offsets: IndexSet) {
    items.remove(atOffsets: offsets)
    self.previousIndex = offsets.first
}

var body: some View {
    ScrollViewReader { (proxy: ScrollViewProxy) in
        List{
            ForEach(items, id: \.self) { Text("\($0)")
            }.onDelete(perform: removeRows)
        }.onChange(of: previousIndex) { (e: Equatable) in
            proxy.scrollTo(previousIndex!-4, anchor: .top)
            //proxy.scrollTo(0, anchor: .top) // will display 1st cell
        }

    }
    
}

}

Теперь это можно упростить с помощью всех новых ScrollViewProxy в Xcode 12, вот так:

struct ContentView: View {
    let itemCount: Int = 100
    var body: some View {
        ScrollViewReader { value in
            VStack {
                Button("Scroll to top") {
                    value.scrollTo(0)
                }
                
                Button("Scroll to buttom") {
                    value.scrollTo(itemCount-1)
                }
                
                ScrollView {
                    LazyVStack {
                        ForEach(0 ..< itemCount) { i in
                            Text("Item \(i)")
                                .frame(height: 50)
                                .id(i)
                        }
                    }
                }
            }
        }
    }
}

MacOS 11: если вам нужно прокрутить список на основе ввода вне иерархии представлений. Я следовал оригинальному шаблону прокси прокрутки, используя новый scrollViewReader:

struct ScrollingHelperInjection: NSViewRepresentable {
    
    let proxy: ScrollViewProxy
    let helper: ScrollingHelper

    func makeNSView(context: Context) -> NSView {
        return NSView()
    }

    func updateNSView(_ nsView: NSView, context: Context) {
        helper.catchProxy(for: proxy)
    }
}

final class ScrollingHelper {
    //updated for mac os v11

    private var proxy: ScrollViewProxy?
    
    func catchProxy(for proxy: ScrollViewProxy) {
        self.proxy = proxy
    }

    func scrollTo(_ point: Int) {
        if let scroller = proxy {
            withAnimation() {
                scroller.scrollTo(point)
            }
        } else {
            //problem
        }
    }
}

Экологический объект:@Published var scrollingHelper = ScrollingHelper()

В представлении: ScrollViewReader { reader in .....

Инъекция в представлении:.background(ScrollingHelperInjection(proxy: reader, helper: scrollingHelper)

Использование вне иерархии представлений: scrollingHelper.scrollTo(3)

Как упоминалось в ответе @ lachezar-todorov, Introspect - хорошая библиотека для доступа к элементам в SwiftUI. Но имейте в виду, что блок, который вы используете для доступа UIKitэлементы вызываются несколько раз. Это действительно может испортить состояние вашего приложения. В моем случае загрузка ЦП составляла 100%, и приложение не отвечало. Мне пришлось использовать некоторые предварительные условия, чтобы этого избежать.

      ScrollView() {
    ...
}.introspectScrollView { scrollView in
    if aPreCondition {
        //Your scrolling logic
    }
}

Еще один классный способ - просто использовать оболочки пространства имен:

Тип динамического свойства, который разрешает доступ к пространству имен, определяемому постоянным идентификатором объекта, содержащего свойство (например, представление).

      struct ContentView: View {
    
    @Namespace private var topID
    @Namespace private var bottomID
    
    let items = (0..<100).map { $0 }
    
    var body: some View {
        
        ScrollView {
            
            ScrollViewReader { proxy in
                
                Section {
                    LazyVStack {
                        ForEach(items.indices, id: \.self) { index in
                            Text("Item \(items[index])")
                                .foregroundColor(.black)
                                .frame(maxWidth: .infinity, alignment: .leading)
                                .padding()
                                .background(Color.green.cornerRadius(16))
                        }
                    }
                } header: {
                    HStack {
                        Text("header")
                        
                        
                        Spacer()
                        
                        Button(action: {
                            withAnimation {
                                proxy.scrollTo(bottomID)
                                
                            }
                        }
                        ) {
                            Image(systemName: "arrow.down.to.line")
                                .padding(.horizontal)
                        }
                    }
                    .padding(.vertical)
                    .id(topID)
                    
                } footer: {
                    HStack {
                        Text("Footer")
                        
                        
                        Spacer()
                        
                        Button(action: {
                            withAnimation {
                                proxy.scrollTo(topID) }
                        }
                        ) {
                            Image(systemName: "arrow.up.to.line")
                                .padding(.horizontal)
                        }
                        
                    }
                    .padding(.vertical)
                    .id(bottomID)
                    
                }
                .padding()
                
                
            }
        }
        .foregroundColor(.white)
        .background(.black)
    }
}

прочитав некоторые комментарии об использовании кнопок вне иерархии, я немного переписал использование прокрутки, на этот раз в надежде, что это поможет:

      import SwiftUI
    
struct ContentView: View {
    let colors: [Color] = [.red, .green, .blue]

    @State private var idx : Int? = nil

    var body: some View {
        VStack{
            
            Button("Jump to #12") {
                idx = 12
            }
            Button("Jump to #1") {
                idx = 1
            }
            
            ScrollViewReader { (proxy: ScrollViewProxy) in
                ScrollView(.horizontal) {
                    HStack(spacing: 20) {
                        
                        ForEach(0..<100) { i in
                            Text("Example \(i)")
                                .font(.title)
                                .frame(width: 200, height: 200)
                                .background(colors[i % colors.count])
                                .id(i)
                        }
                    } // HStack
                    
                }// ScrollView
                .frame(height: 350)
                .onChange(of: idx) { newValue in
                    withAnimation {
                        proxy.scrollTo(idx, anchor: .bottom)
                    }
                }

            } // ScrollViewReader
        }
    }
}

Две части:

  1. Оберните (илиScrollView) с
  2. ИспользоватьscrollViewProxy(что происходит отScrollViewReader), чтобы перейти кidэлемента вList. Вы можете, казалось бы, использоватьEmptyView().

В приведенном ниже примере используется уведомление для простоты (используйте вместо этого функцию, если можете!).

      ScrollViewReader { scrollViewProxy in
  List {
    EmptyView().id("top")  
  }
  .onReceive(NotificationCenter.default.publisher(for: .ScrollToTop)) { _ in
    // when using an anchor of `.top`, it failed to go all the way to the top
    // so here we add an extra -50 so it goes to the top
    scrollViewProxy.scrollTo("top", anchor: UnitPoint(x: 0, y: -50))
  }
}

extension Notification.Name {
  static let ScrollToTop = Notification.Name("ScrollToTop")
}

NotificationCenter.default.post(name: .ScrollToTop, object: nil)
Другие вопросы по тегам