I am working on a View that has following requirements:
- Show list of Sections with "items" in each section.
- A header with pills that allows filtering the "items" based on different criteria. Some pills may trigger a new API call to fetch items.
- support two way infinite-scroll/ paginate
- scrollTo a particular section everytime the view is loaded for the first time.
- when user interacts with the scrollView, retain the scroll position when new data is added to the top of the list, or if the list changes based on a selected filter.
When I apply the filters and only a small number of rows are added or removed the View is well-behaved. If im adding new items to the bottom of the list, that too works well. But if a large number of fields are hidden or added by the header filter, of im adding items to the top of the list, the scroll position shifts dramatically. I want to tame this behavior as much as possible to make this less disorienting for the user. Does LazyVStack or List offer more controls that can help me achieve this? I am not experienced with UIKit but read somewhere that the Table or List in UIKit may offer more controls?
Should I do this manually? --- keep track of which sections(headers) are visible to the user using GeometryReader and then scrollTo(sectionId). Maybe wrap it inside a DispatchQueue to delay the scroll a bit so the data can load and UI had enough time to render? That seems like sad way to retain scroll position and will definitely break of the network calls occasionally takes too long.
import SwiftUI
@main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class ViewModel: ObservableObject {
var items: [Item] = []
@Published var filteredItems: [Item] = []
var filterEnabled: Bool {
gotVowelSelected || shortWordsSelected || longWordsSelected
}
@Published var gotVowelSelected = false {
didSet { filterData() }
}
@Published var shortWordsSelected = false {
didSet { filterData() }
}
@Published var longWordsSelected = false {
didSet { filterData() }
}
init() {
items = createItemList(start: -2, end: 30)
filterData()
}
private func createItemList(start:Int, end:Int) -> [Item] {
var items: [Item] = []
for num in (start...end) {
var names: Set<String> = []
for n in (1...Int.random(in: 2..<10)) {
while (names.count < n) {
let uuid = String(UUID().uuidString.lowercased().shuffled())
.components(separatedBy: CharacterSet.decimalDigits)
.joined().filter { $0 != "-" }
let name = String(num) + uuid.prefix(Int.random(in: 3..<8))
names.insert(name)
}
}
items.append(Item(index: num, names: Array(names)))
}
return items
}
func addTop() {
let endIndex = items.first!.index
let newItems = createItemList(start: endIndex - 7, end: endIndex)
filteredItems.insert(contentsOf: newItems, at: 0)
}
private func filterData() {
var filteredItems: [Item] = []
for item in items {
var filteredNames: [String] = []
for name in item.names {
if (
!filterEnabled || // no filters selected
(gotVowelSelected && name.rangeOfCharacter(from: CharacterSet(charactersIn: "aeiou")) != nil) ||
(shortWordsSelected && name.count < 5) ||
(longWordsSelected && name.count >= 5)
) {
filteredNames.append(name)
}
}
filteredItems.append(Item(index: item.index, names: filteredNames))
}
return self.filteredItems = filteredItems
}
}
struct Item: Identifiable {
var id: Int { return index }
var index: Int
var names: [String]
}
struct ContentView: View {
@StateObject var viewModel: ViewModel = ViewModel()
var body: some View {
VStack {
HStack {
Button {
withAnimation {
viewModel.gotVowelSelected.toggle()
}
} label: {
Text("GotVowel")
.foregroundColor(viewModel.gotVowelSelected ? .purple : .black )
}
Button {
withAnimation {
viewModel.shortWordsSelected.toggle()
}
} label: {
Text("ShortWords")
.foregroundColor(viewModel.shortWordsSelected ? .purple : .black )
}
Button() {
withAnimation {
viewModel.longWordsSelected.toggle()
}
} label: {
Text("LongWords")
.foregroundColor(viewModel.longWordsSelected ? .purple : .black )
}
Button() {
withAnimation {
viewModel.addTop()
}
} label: {
Text("AddTop")
.foregroundColor(.black)
}
Spacer()
}
.padding([.leading, .trailing])
Divider()
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: true) {
LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
ForEach(viewModel.filteredItems) { item in
Section {
if let names = item.names, !names.isEmpty {
ForEach(names, id: \.self) { name in
CellView(name: name)
}
} else {
Text("-- No Results --")
}
} header: {
SectionHeader(num: item.index)
}
.id(item.index)
}
}
}
.onAppear {
withAnimation {
proxy.scrollTo( 0, anchor: .top)
}
}
}
}
}
}
struct SectionHeader: View {
var num: Int
var body: some View {
HStack {
Text(String(num))
Spacer()
}
.padding()
.background(.gray)
}
}
struct CellView: View {
var name: String
var body: some View {
Text(name)
.padding()
Divider()
}
}
struct CellHider: ViewModifier {
var num: Int
var boundaryNum: Int
func body(content: Content) -> some View {
if num < boundaryNum {
content
.frame(height: 0)
} else {
content
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}