ScrollView doesn't maintain position when pre-pending elements - swiftui

I'm building a chat app in SwiftUI using a ScrollView and a ForEach statement. Clearly the chat app needs to scroll to the bottom when the view first loads, so the latest message is shown.
As part of the chat app, I'm only loading 20 elements in view. As the user scrolls up, I'm retrieving more messages and pre-pending them to the relevant array. However, when I do this, the ScrollView scrolls to the top automatically, instead of maintaining it's position.
What do I need to adjust to get this to work?
import SwiftUI
struct ListModel {
var id = UUID().uuidString
var text: String
}
class ListViewModel : ObservableObject {
#Published var items: [ListModel] = [
ListModel(text: "Item 1"),
ListModel(text: "Item 2"),
ListModel(text: "Item 3"),
ListModel(text: "Item 4"),
ListModel(text: "Item 5"),
ListModel(text: "Item 6"),
ListModel(text: "Item 7"),
ListModel(text: "Item 8"),
ListModel(text: "Item 9"),
ListModel(text: "Item 10"),
ListModel(text: "Item 11"),
ListModel(text: "Item 12"),
ListModel(text: "Item 13"),
ListModel(text: "Item 14"),
ListModel(text: "Item 15"),
ListModel(text: "Item 16"),
ListModel(text: "Item 17"),
ListModel(text: "Item 18"),
ListModel(text: "Item 19"),
ListModel(text: "Item 20")
]
#Published var prependList: [ListModel] = [
ListModel(text: "Item -20"),
ListModel(text: "Item -19"),
ListModel(text: "Item -18"),
ListModel(text: "Item -17"),
ListModel(text: "Item -16"),
ListModel(text: "Item -15"),
ListModel(text: "Item -14"),
ListModel(text: "Item -13"),
ListModel(text: "Item -12"),
ListModel(text: "Item -11"),
ListModel(text: "Item -10"),
ListModel(text: "Item -9"),
ListModel(text: "Item -8"),
ListModel(text: "Item -7"),
ListModel(text: "Item -6"),
ListModel(text: "Item -5"),
ListModel(text: "Item -4"),
ListModel(text: "Item -3"),
ListModel(text: "Item -2"),
ListModel(text: "Item -1")
]
}
struct ContentView: View {
#StateObject var listViewModel = ListViewModel()
#State var prependListCalled = false
#State var onAppearCalled = false
var body: some View {
ScrollViewReader { scrollView in
ScrollView {
LazyVStack {
ForEach(listViewModel.items, id: \.id) { item in
Text(item.text)
.padding(.vertical, 20)
.onAppear {
if item.id == listViewModel.items.first!.id && prependListCalled == false && onAppearCalled == true {
listViewModel.items.insert(contentsOf: listViewModel.prependList, at: 0)
prependListCalled = true
}
}
}
}
}
.onAppear {
scrollView.scrollTo(listViewModel.items.last!.id, anchor: .bottom)
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
onAppearCalled = true
}
}
}
.environmentObject(listViewModel)
}
}
As a test, I've checked if appending to the array works and the ScrollView position is maintained. This seems to work as expected. See code below which shows that if I load the view without automatically scrolling to the bottom, then scroll manually to the bottom, the array gets appended and the ScrollView position is maintained.
import SwiftUI
struct ListModel {
var id = UUID().uuidString
var text: String
}
class ListViewModel : ObservableObject {
#Published var items: [ListModel] = [
ListModel(text: "Item 1"),
ListModel(text: "Item 2"),
ListModel(text: "Item 3"),
ListModel(text: "Item 4"),
ListModel(text: "Item 5"),
ListModel(text: "Item 6"),
ListModel(text: "Item 7"),
ListModel(text: "Item 8"),
ListModel(text: "Item 9"),
ListModel(text: "Item 10"),
ListModel(text: "Item 11"),
ListModel(text: "Item 12"),
ListModel(text: "Item 13"),
ListModel(text: "Item 14"),
ListModel(text: "Item 15"),
ListModel(text: "Item 16"),
ListModel(text: "Item 17"),
ListModel(text: "Item 18"),
ListModel(text: "Item 19"),
ListModel(text: "Item 20")
]
#Published var appendList: [ListModel] = [
ListModel(text: "Item 21"),
ListModel(text: "Item 22"),
ListModel(text: "Item 23"),
ListModel(text: "Item 24"),
ListModel(text: "Item 25"),
ListModel(text: "Item 26"),
ListModel(text: "Item 27"),
ListModel(text: "Item 28"),
ListModel(text: "Item 29"),
ListModel(text: "Item 30"),
ListModel(text: "Item 31"),
ListModel(text: "Item 32"),
ListModel(text: "Item 33"),
ListModel(text: "Item 34"),
ListModel(text: "Item 35"),
ListModel(text: "Item 36"),
ListModel(text: "Item 37"),
ListModel(text: "Item 38"),
ListModel(text: "Item 39"),
ListModel(text: "Item 40")
]
}
struct ContentView: View {
#StateObject var listViewModel = ListViewModel()
#State var appendListCalled = false
var body: some View {
ScrollView {
LazyVStack {
ForEach(listViewModel.items, id: \.id) { item in
Text(item.text)
.padding(.vertical, 20)
.onAppear {
if item.id == listViewModel.items.last!.id && appendListCalled == false {
listViewModel.items.append(contentsOf: listViewModel.appendList)
appendListCalled = true
}
}
}
}
}
.environmentObject(listViewModel)
}
}

Related

ProgressView inside SwiftUI List disappearing after list is updated

The circular (default) SwiftUI ProgressView disappears within a List whenever the list gets expanded with new content because of a .onAppear modifier on the ProgressView. 
The ProgressView becomes invisible, although its allocated cell (and its label, if assigned) within the list remain visible.
I want to keep the ProgressView at the bottom of the list so that the list gets automatically updated with new content being downloaded from the server when the user reaches the end of the list.
Possible duplicate of this article but the accepted answer does not tackle the reason why the view disappears.
Reprex:
struct ContentView: View {
#State var items: [String] = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6"]
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text(item)
.frame(height: 100)
}
ProgressView {
Text("Loading")
}
.frame(maxWidth: .greatestFiniteMagnitude, alignment: .center)
.onAppear(perform: endOfListReached)
}
}
func endOfListReached() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
items.append("Item \(items.count + 1)")
}
}
}
You could try this alternative approach using a ScrollViewReader and ScrollView
to keep the ProgressView at the bottom of the list
so that the list gets automatically updated with new content being
downloaded from the server when the user reaches the end of the list.
struct ContentView: View {
#State var items: [String] = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6"]
#Namespace var bottomID // id for the ProgressView
var body: some View {
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: false){
ForEach(items, id: \.self) { item in
Text(item).frame(height: 100)
}
ProgressView { Text("Loading") }.id(bottomID)
}
.frame(maxWidth: .greatestFiniteMagnitude, alignment: .center)
.onAppear(perform: endOfListReached)
.onChange(of: items) { _ in
withAnimation {
proxy.scrollTo(bottomID) // <-- scroll to the ProgressView
}
}
}
}
func endOfListReached() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
items.append("Item \(items.count + 1)")
if items.count < 15 {
endOfListReached() // <--- testing up to 15
}
}
}
}
I would rather keep the List for its convenient layout, but it appears that the List is causing the issue here and that changing it to a ScrollView and LazyVStack and styling it with Dividers works as expected.

Why is my SwiftUI #State variable null the fist time my sheet appears?

In the view shown below there is a simple list of 3 items. When the user selects an item in the list a sheet is presented showing the name of the item selected. However, when I run it, the first time I select an item it shows "item was null". If I select another item in the list it works perfectly. If I go back and select the first item again, it now works.
Can someone tell me why the selectedItem variable is null the first time a button is clicked?
struct TestView: View {
#State private var showSheet = false
#State private var selectedItem: String?
var body: some View {
List {
Button(action: {
selectedItem = "Item 1"
showSheet = true
}, label: { Text("Item 1") })
Button(action: {
selectedItem = "Item 2"
showSheet = true
}, label: { Text("Item 2") })
Button(action: {
selectedItem = "Item 3"
showSheet = true
}, label: { Text("Item 3") })
}
.sheet(isPresented: $showSheet) {
Text(selectedItem ?? "item was null")
}
}
}
As discussed in the comments, you're going to need to use .sheet(item:). The reason for this is that the common .sheet(isPresented:) is created before the button is tapped.
When using .sheet(item:) the string needs to be identifiable, but you can achieve that with a simple extension.
And now you also don't need the showSheet bool, because the sheet is now bound to selectedItem. Keep in mind that this means that selectedItem is set to nil when the sheet is dismissed.
An example of what this solution could look like for your code is as follows:
struct TestView: View {
#State private var selectedItem: String?
var body: some View {
List {
Button(action: {
selectedItem = "Item 1"
}, label: { Text("Item 1") })
Button(action: {
selectedItem = "Item 2"
}, label: { Text("Item 2") })
Button(action: {
selectedItem = "Item 3"
}, label: { Text("Item 3") })
}
.sheet(item: $selectedItem, content: { item in
Text(item)
})
}
}
extension String: Identifiable {
public var id: String { return self }
}

SwiftUI: change background color of item in a list when it's tapped on

I have a horizontal scrollview with a list of items, I want to change background color color of items when the user taps on it. This is my code but when I run it and click on items nothing happens.
struct HorizontalList: View {
var list = ["item 1", "item 2", "item 3", "item 4", "item 5", "item 6", "item 7", "item 8", "item 9", "item 10"]
#State var selectedIndex = 0
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(0..<list.count) { index in
ListItem(isSelected: selectedIndex == index, label: list[index])
.listRowBackground(Color.blue)
.onTapGesture {
selectedIndex = index
}
}
}
}
}
}
struct ListItem: View {
#State var isSelected: Bool
#State var label: String
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 8)
.foregroundColor(isSelected ? Color.blue : Color.clear)
.frame(minHeight: 16, idealHeight: 16, maxHeight: 16)
Text(label)
}
}
}
You need to use a .simultaneousGesture as the list is using a DragGesture behind the scenes.
.simultaneousGesture(
TapGesture()
.onEnded({ _ in
selectedIndex = index
})
)
Edit:
See the comments in the code.
struct HorizontalList: View {
var listItems = Array(1...10).map( { ListItem(text: "Item \($0)") })
#State var selectedIndex = 0
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
// It is good practice to always supply a ForEach with an identifiable. If you were
// to try to delete an element in the array, you can cause a crash without this.
// What Array(zip()) does is takes listItems and listItems.indices and put them together
// into an array of pairs, requiring two arguments in the ForEach, one for the item and
// one for the index. You can then use both in the loop. However, the ForEach tracks them by
// the identifiable element, not the index which is what the id: \.0 does.
ForEach(Array(zip(listItems, listItems.indices)), id: \.0) { item, index in
ListItemView(isSelected: selectedIndex == index, label: item.text)
.listRowBackground(Color.blue)
.simultaneousGesture(
TapGesture()
.onEnded({ _ in
selectedIndex = index
})
)
}
}
}
}
}
struct ListItemView: View {
// There is no reason for these to be an #State var unless this view changes them. If it does,
// it really should be a Binding to pass the data back.
let isSelected: Bool
let label: String
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 8)
.foregroundColor(isSelected ? Color.blue : Color.clear)
.frame(minHeight: 16, idealHeight: 16, maxHeight: 16)
Text(label)
}
}
}
// This becomes the item you itereate on
struct ListItem:Hashable, Identifiable {
let id = UUID()
var text: String
}

Button action in list is executed even when taped outside (but inside the row)

I have a button in a view that is used for entries in a list.
Currently, the action of the button is executed no matter where you tap within the row.
The following sample code illustrates this:
import SwiftUI
// MARK: - Model
struct Item {
var id: Int
var name: String
var subItems: [Item]
var isFolder: Bool { subItems.count > 0 }
}
extension Item: Identifiable { }
class Model: ObservableObject {
var items: [Item]
internal init(items: [Item]) { self.items = items }
}
extension Model {
static let exmaple = Model(items: [
Item(id: 1, name: "Folder 1", subItems: [
Item(id: 11, name: "Item 1.1", subItems: []),
Item(id: 12, name: "Item 1.2", subItems: [])
]),
Item(id: 2, name: "Folder 2", subItems: [
Item(id: 21, name: "Item 2.1", subItems: []),
Item(id: 22, name: "Item 2.2", subItems: []),
Item(id: 23, name: "Item 2.3", subItems: [])
]),
Item(id: 3, name: "Item 1", subItems: []),
Item(id: 4, name: "Item 2", subItems: [])
])
}
// MARK: - View
struct MainView: View {
var body: some View {
NavigationView {
Form {
Section("Main…") {
NavigationLink {
ItemListView(items: Model.exmaple.items)
} label: {
Label("List", systemImage: "list.bullet.rectangle.portrait")
}
}
}
Label("Select a menu item", image: "menucard")
}
}
}
struct ItemListView: View {
var items: [Item]
var body: some View {
List(items) { item in
if item.isFolder {
NavigationLink {
ItemListView(items: item.subItems)
} label: {
ItemListCell(item: item)
}
} else {
ItemListCell(item: item)
}
}
.navigationBarTitle("List")
}
}
struct ItemListCell: View {
var item: Item
var body: some View {
HStack {
Image(systemName: item.isFolder ? "folder" : "doc")
Text(String(item.name))
Spacer()
if !item.isFolder {
Button {
print("button 1 item: \(item.name)")
} label: {
Image(systemName: "ellipsis.circle")
}
Button {
print("button 2 item: \(item.name)")
} label: {
Image(systemName: "info.circle")
}
}
}
}
}
// MARK: - App
#main
struct SwiftUI_TestApp: App {
var body: some Scene {
WindowGroup {
MainView()
}
}
}
Here, the ItemListCell struct is important. It contains two buttons. If I now tap somewhere in the row, the code for both buttons is executed.
Is this the way it should be?
I would like to achieve that tapping the buttons executes the corresponding code and when tapping the row somewhere else, a third action should be executed.

Dual Line Form Picker

I am trying to create a form picker that displays two rows of data / entry. The top row displays correctly showing 2 items with slightly different fonts. The third item is displaying on the second line separated by a horizontal form line. I'm also getting two check marks--one for each line.
...
var currCountry: [String] = ["con 1", "con 2", "con 3", "con 4"]
var currSymbol: [String] = ["sym 1", "sym 2", "sym 3", "sym 4"]
var currName: [String] = ["name 1", "name 2", "name 3", "name 4"]
#ObservedObject var userData = UserData()
var body: some View {
NavigationView {
Form {
Picker("select", selection: $userData.entry) {
ForEach(0 ..< self.currCountry.count) { i in
HStack {
Text(currCountry[i])
.font(.caption)
Text(currSymbol[i])
.font(.caption2)
}
Text(currName[i])
.font(.caption2)
}
}
}.navigationBarTitle("important info")
}
}
...
Unsure what your question is, so I'll take a leap. Are you referring to that you want to have two items horizontally, and then the third vertical to that? Such as:
VStack {
HStack {
Text(currCountry[i])
.font(.caption)
Text(currSymbol[i])
.font(.caption2)
}
Text(currName[i])
.font(.caption2)
}
This way there is only one checkmark at this point? If this isn't want you were referring to, can you please give an example of what type of output you are looking for.