Как программно прокручивать список в 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
}
}
}
Две части:
- Оберните (или
ScrollView
) с - Использовать
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)