🐷

SwiftUIでスクロール位置をキープ(維持)する方法

2023/05/17に公開

SwiftUIでスクロール位置がずれる

SwiftUIのList, ScrollViewでデータを表示している際に、一番下にデータが追加される分にはスクロール位置がキープされるのですが、上にデータが追加されると一番上まで自動でスクロールされてしまいます。これを解決していきます。

実装の考え方

ScrollViewReaderを使えば、特定のViewのidにスクロール位置を移動させることができるのでそれを使用します。

そして、表示されているViewのidを保持しておいて、データ追加後にScrollViewReaderを使って移動させます。

実装

struct ContentView: View {
  @State var items: [Item] = (1...100).map { .init(id: $0) }
  @State var displayItemIDs: Set<Int> = []
  @State var scrollPosition: ScrollPosition<Int>?
  
  var displayItems: [Item] {
    items.sorted { $0.id > $1.id }
  }
  
  var title: String {
    guard let max = displayItemIDs.max().map({ "\($0)" }) else {
      return "Nothing"
    }
    
    let min = displayItemIDs.min().map { "\($0)" } ?? ""
    
    return "\(max) ~ \(min)"
  }
  
  var body: some View {
    NavigationStack {
      ScrollViewReader { proxy in
        List {
          ForEach(displayItems) { item in
            Label("\(item.id)", systemImage: "person")
              .id(item.id)
              .onAppear { displayItemIDs.insert(item.id) }
              .onDisappear { displayItemIDs.remove(item.id) }
          }
        }
        .onChange(of: scrollPosition) { scrollPosition in
          guard let scrollPosition else { return }
          proxy.scrollTo(scrollPosition.itemID, anchor: scrollPosition.anchor)
        }
      }
      .navigationTitle(title)
      .navigationBarTitleDisplayMode(.inline)
      .toolbar {
        ToolbarItem(placement: .navigationBarTrailing) {
          Button("ADD") {
            let topDisplayItemID = displayItemIDs.max()
            let first = items.map(\.id).max() ?? 0
            let newItems: [Item]  = ((first + 1)...(first + 10)).map { .init(id: $0) }
            items.append(contentsOf: newItems)
            
            if let topDisplayItemID {
              scrollPosition = .init(itemID: topDisplayItemID, anchor: .top)
            }
          }
        }
      }
    }
  }
}

struct Item: Identifiable {
  let id: Int
}

struct ScrollPosition<ContentID: Hashable>: Equatable {
  let id = UUID()
  let itemID: ContentID
  let anchor: UnitPoint
}

問題点(Viewのidが順序不可能)

メモなどのIDがUUIDなどの場合ViewのIDが順序不可能であり、let topDisplayItemID = displayItemIDs.max()を使用しているため上のやり方は不可能です。

その場合は、displayItemIDsの中央値を使用して、proxy.scrollTo(itemID, anchor: .center)などで対処するなどが良いです。ただ理想とはかけ離れた動きになります。
もっとうまくやりたい場合は、メモ作成日付などを使用することでうまくいくと思います。

Discussion