SwiftUI static List weird reuse behavior - ios

I'm facing a strange behavior using a static List in SwiftUI. I can't determine if it's a SwiftUI bug or something I'm doing wrong. I have a very simple List that looks like this :
var body: some View {
List {
SettingsPickerView<TrigonometryUnit>(title: "Trigonometry Units", selection: $viewModel.trigonometryUnitIndex, items: TrigonometryUnit.allCases)
SettingsPickerView<DecimalSeparator>(title: "Decimal Separator", selection: $viewModel.decimalSeparatorIndex, items: DecimalSeparator.allCases)
SettingsPickerView<GroupingSeparator>(title: "Grouping Separator", selection: $viewModel.groupingSeparatorIndex, items: GroupingSeparator.allCases)
SettingsPickerView<ExponentSymbol>(title: "Exponent Symbol", selection: $viewModel.exponentSymbolIndex, items: ExponentSymbol.allCases)
}
}
Each cell of the List looks like this :
struct SettingsPickerView<T: Segmentable>: View {
let title: String
#Binding var selection: Int
let items: [T]
var body: some View {
Section(header: Text(title)) {
ForEach(items.indices) { index in
self.cell(for: self.items[index], index: index)
}
}
}
private func cell(for item: T, index: Int) -> some View {
print(title, item.title, items.map({ $0.title }))
return Button(action: {
self.selection = index
}, label: {
HStack {
Text(item.title)
Spacer()
if index == self.selection {
Image(systemName: "checkmark")
.font(.headline)
.foregroundColor(.rpnCalculatorOrange)
}
}
})
}
}
And finally, this is what a Segmentable object looks like:
enum GroupingSeparator: Int, CaseIterable {
case defaultSeparator
case space
case comma
}
extension GroupingSeparator: Segmentable {
var id: String {
switch self {
case .defaultSeparator:
return "groupingSeparator.default"
case .space:
return "groupingSeparator.space"
case .comma:
return "groupingSeparator.comma"
}
}
var title: String {
switch self {
case .defaultSeparator:
return "Default"
case .space:
return "Space"
case .comma:
return "Comma"
}
}
}
When the SettingsView is loaded. everything looks fine. But as soon as I start scrolling, and some other cells are instantiated, there are some cell displayed, but not the proper ones. Here is some screenshots and logs.
When the view is loaded, no scrolling, here is what the screen looks like:
But, what I got on the console is pretty weird and doesn't follow the order of the SettingsPickerView written in the main View:
Trigonometry Units Radians ["Radians", "Degrees"] <-- Fine
Trigonometry Units Degrees ["Radians", "Degrees"] <-- Fine
Decimal Separator Default ["Default", "Dot", "Comma"] <-- Fine
Decimal Separator Default ["Default", "Dot", "Comma"] <-- Fine
Trigonometry Units Degrees ["Radians", "Degrees"] <-- Not expected. Should be Grouping Separator
Trigonometry Units Radians ["Radians", "Degrees"] <-- Not expected. Should be Grouping Separator
The second section is ok and properly displayed:
But the third section is completely broken:
The third section displays its title properly, but display some of the data of the first section. I tried to add an identifier to the button in the cell because the issue looks like SwiftUI can't identify the proper data. But adding an identifier to the button broke the binding, and the checkbox don't change anymore.
private func cell(for item: T, index: Int) -> some View {
print(title, item.title, items.map({ $0.title }))
return Button(action: {
self.selection = index
}, label: {
HStack {
Text(item.title)
Spacer()
if index == self.selection {
Image(systemName: "checkmark")
.font(.headline)
.foregroundColor(.rpnCalculatorOrange)
}
}
})
.id(UUID().uuidString) // This solve the display issue but broke the binding.
}
Does someone experienced something like this before ?
Thanks in advance for your help.

Here is fixed block of code (due to used indexes only List is confused and reuses rows, so solution is to make rows identifiable by items).
Tested with Xcode 11.4
struct PickerView<T: Segmentable>: View {
// ... other code here
var body: some View {
Section(header: Text(title)) {
// Corrected section construction !!
ForEach(Array(items.enumerated()), id: \.element.id) { index, _ in
self.cell(for: self.items[index], index: index)
}
}
}
// ... other code here

Related

Segmented picker not letting me pick a different selection

I have a segmented picker with three options that defaults to the first option. The problem is that when I select any of the other ones, it will not change for some reason.
This is what it looks like.
#Binding var transaction: TransactionModel
var body: some View {
VStack {
DatePicker(
"Transaction Date",
selection: $transaction.date,
displayedComponents: [.date]
)
Divider()
Picker("Type", selection: $transaction.type) {
ForEach(TransactionModel.TransactionType.allCases, id:\.self) { tType in
Text(tType.rawValue.capitalized)
}
}.pickerStyle(SegmentedPickerStyle())
}
.padding()
}
This is the code for the view so far, and below is the code for TransactionModel.
struct TransactionModel {
var date = Date()
var type = TransactionType.income
static let `default` = TransactionModel()
enum TransactionType: String, CaseIterable, Identifiable {
case income = "Income"
case expense = "Expense"
case transfer = "Transfer"
var id: String {self.rawValue}
}
}
One thing to note, it will work with the inline picker style. That will let me change between those three options, however, I want to use the segmented one. Can anyone help me figure out what's going on with this?
Edit: I actually found that the inline picker style doesn't seem to be working either. I added some code to display a text view based on what was selected, and it never changed from what it said when Income was selected. But that could be due to my code itself. Below is the code for that.
Picker("Type", selection: $transaction.type) {
ForEach(TransactionModel.TransactionType.allCases, id:\.self) { tType in
Text(tType.rawValue.capitalized)
}
}.pickerStyle(InlinePickerStyle())
Divider()
if(transaction.type == TransactionModel.TransactionType.income) {
Text("Income Selected")
Divider()
}else if(transaction.type == TransactionModel.TransactionType.expense) {
Text("Expense Selected")
Divider()
}

SwiftUI - Prevent Sections from flying/zooming to the right in List when dynamically filtering them

I originally asked this question:
SwiftUI - Dynamic List filtering animation flies to right side when data source is empty
There, I had a List without sections. I was filtering them so that it only showed the rows that contained the text inside a TextField. The solution was to wrap everything inside the List in a Section.
Unfortunately, I now need to filter Sections. Here's my code:
struct Group: Identifiable {
let id = UUID() /// required for the List
var groupName = ""
var people = [Person]()
}
struct Person: Identifiable {
let id = UUID() /// required for the List
var name = ""
}
struct ContentView: View {
#State var searchText = ""
var groups = [
Group(groupName: "A People", people: [
Person(name: "Alex"),
Person(name: "Ally"),
Person(name: "Allie")
]),
Group(groupName: "B People", people: [
Person(name: "Bob")
]),
Group(groupName: "T People", people: [
Person(name: "Tim"),
Person(name: "Timothy")
])
]
var body: some View {
VStack {
TextField("Search here", text: $searchText) /// text field
.padding()
List {
ForEach(
/// Filter the groups for those that contain searchText
groups.filter { group in
searchText.isEmpty || group.groupName.localizedStandardContains(searchText)
}
) { group in
Section(header: Text(group.groupName)) {
ForEach(group.people) { person in
Text(person.name)
}
}
}
}
.animation(.default) /// apply the animation
}
}
}
Result:
I pass in a filtered array in the ForEach to determine the Sections. However, whenever that array changes, the List animates really weirdly. The Sections zoom/fly to the right side, and come back from the left when the array includes them again. How can I avoid this animation?
If I remove .animation(.default), it doesn't animate at all, as expected. But, I would still like an animation. Is there a way to fade the changes, or slide them instead?
The solution is not using List. As long as you're not using selection and row deleting a ScrollView is basically the same.
If you want to style it a bit like the List that's also not that hard:
struct SearchAnimationExample: View {
...
var body: some View {
VStack {
TextField("Search here", text: $searchText) /// text field
.padding()
ScrollView {
VStack(spacing: 0) {
ForEach(
groups.filter { group in
searchText.isEmpty || group.groupName.localizedStandardContains(searchText)
}
) { group in
Section(header: header(title: group.groupName)) {
ForEach(group.people) { person in
row(for: person)
Divider()
}
}
}.transition(.opacity) // Set which transition you would like
// Always full width
HStack { Spacer() }
}
}
.animation(.default)
}
}
func header(title: String) -> some View {
HStack {
Text(title).font(.headline)
Spacer()
}
.padding(.horizontal)
.background(Color.gray.opacity(0.4))
}
func row(for person: Person) -> some View {
HStack {
Text(person.name)
Spacer()
}.padding()
}
}
Looks practically the same as the default list:

Unwanted animation when moving items in SwiftUI list

I have a SwiftUI List like in the example code below.
struct ContentView: View {
#State var numbers = ["1", "2", "3"]
#State var editMode = EditMode.inactive
var body: some View {
NavigationView {
List {
ForEach(numbers, id: \.self) { number in
Text(number)
}
.onMove {
self.numbers.move(fromOffsets: $0, toOffset: $1)
}
}
.navigationBarItems(trailing: EditButton())
}
}
}
When I enter edit mode and move the item one position up the strange animation happens after I drop the item (see the gif below). It looks like the dragged item comes back to its original position and then moves to the destination again (with animation)
What's interesting it doesn't happen if you drag the item down the list or more than one position up.
I guess it's because the List performs animation when the items in the state get reordered even though they were already reordered on the view side by drag and drop. But apparently it handles it well in all the cases other than moving item one position up.
Any ideas on how to solve this issue? Or maybe it's a known bug?
I'm using XCode 11.4.1 and the build target is iOS 13.4
(Please also note that in the "real world" app I'm using Core Data and when moving items their order is updated in the DB and then the state is updated, but the problem with the animation looks exactly the same.)
Here is solution (tested with Xcode 11.4 / iOS 13.4)
var body: some View {
NavigationView {
List {
ForEach(numbers, id: \.self) { number in
HStack {
Text(number)
}.id(UUID()) // << here !!
}
.onMove {
self.numbers.move(fromOffsets: $0, toOffset: $1)
}
}
.navigationBarItems(trailing: EditButton())
}
}
Here's a solution based on Mateusz K's comment in the accepted answer. I combined the hashing of order and number. I'm using a complex object in place of number which gets dynamically updated. This way ensures the list item refreshes if the underlying object changes.
class HashNumber : Hashable{
var order : Int
var number : String
init(_ order: Int, _ number:String){
self.order = order
self.number = number
}
static func == (lhs: HashNumber, rhs: HashNumber) -> Bool {
return lhs.number == rhs.number && lhs.order == rhs.order
}
//
func hash(into hasher: inout Hasher) {
hasher.combine(number)
hasher.combine(order)
}
}
func createHashList(_ input : [String]) -> [HashNumber]{
var r : [HashNumber] = []
var order = 0
for i in input{
let h = HashNumber(order, i)
r.append(h)
order += 1
}
return r
}
struct ContentView: View {
#State var numbers = ["1", "2", "3"]
#State var editMode = EditMode.inactive
var body: some View {
NavigationView {
List {
ForEach(createHashList(numbers), id: \.self) { number in
Text(number.number)
}
.onMove {
self.numbers.move(fromOffsets: $0, toOffset: $1)
}
}
.navigationBarItems(trailing: EditButton())
}
}
}
I know that this wasn't what caused issues for the Author of this Question, but I identified what caused the animation glitch for my list.
I was setting the id of each View produced by the ForEach View within my List View, to one that would get assigned to another one of those Views after dragging and dropping a row to re-order it, as the id's were set based on a common substring, paired with the index with which the given ForEach-iteration's product corresponded.
Here's a simple code snippet to demonstrate the mistake I (but not the Author of this Question) made:
struct ContentView: View {
private let shoppingListName: String
#FetchRequest
private var products: FetchedResults<Product>
init(shoppingList: ShoppingList) {
self.shoppingListName = shoppingList.name?.capitalized ?? "Unknown"
self._products = FetchRequest(
sortDescriptors: [
.init(keyPath: \Product.orderNumber, ascending: true)
],
predicate: .init(format: "shoppingList == %#", shoppingList.objectID)
)
var body: some View {
NavigationView {
List {
ForEach(Array(products.enumerated()), id: \element.objectID) { index, product in
Text(product.name ?? "Unknown")
.id("product-\(index)") // Problematic
}
.onMove {
var products = Array(products)
products.move(fromOffsets: $0, toOffset: $1)
for (index, product) in products.enumerated() {
product.orderNumber = Int64(index)
}
}
}
.toolbar {
ToolbarItem {
EditButton()
}
}
.navigationBarTitle("\(shoppingListName) Shopping List")
}
}
}
If, when running the above code, I were to reorder item at index 2 (i.e. row with id "product-2") to index 0, then the row I'd reordered would start having the id which the row that was previously at index 0 had. And the row that was previously at index 0 would start having the id of the row directly below it, and so on and so forth.
This re-assignment of existing id's to other Views within the same list in response to a row being reordered within that list, would confuse SwiftUI, and cause there to be an animation glitch whilst the row being reordered moved into its correct new position after having been dropped.
P.S. I recommend that readers who investigate their code to see if they've made this same mistake, do the following:
Check to see if you're setting the id's of the "row" Views based on any value that can get "shifted around" amongst the rows within the list, in response to a row being reordered. Such a value could be an index, but it could also be something else, such as the value of an orderNumber property that you store in the NSManagedObject-Subclass instances over which you're looping in the ForEach View.
Also, check to see if you're calling any custom View methods on the "row" Views, and if so, investigate to see whether or not any of those custom View methods are setting id's for the View's on which they're being called. <-- This was the case in my real code, which made my mistake a bit harder for me to spot :P!
I have same problem. I do not know bug or not, but I found workaround solution.
class Number: ObservableObject, Identifiable {
var id = UUID()
#Published var number: String
init(_ number: String) {
self.number = number
}
}
class ObservedNumbers: ObservableObject {
#Published var numbers = [ Number("1"), Number("2"), Number("3") ]
func onMove(fromOffsets: IndexSet, toOffset: Int) {
var newNumbers = numbers.map { $0.number }
newNumbers.move(fromOffsets: fromOffsets, toOffset: toOffset)
for (newNumber, number) in zip(newNumbers, numbers) {
number.number = newNumber
}
self.objectWillChange.send()
}
}
struct ContentView: View {
#ObservedObject var observedNumbers = ObservedNumbers()
#State var editMode = EditMode.inactive
var body: some View {
NavigationView {
List {
ForEach(observedNumbers.numbers) { number in
Text(number.number)
}
.onMove(perform: observedNumbers.onMove)
}
.navigationBarItems(trailing: EditButton())
}
}
}
In CoreData case I just use NSFetchedResultsController. Implementation of ObservedNumbers.onMove() method looks like:
guard var hosts = frc.fetchedObjects else {
return
}
hosts.move(fromOffsets: set, toOffset: to)
for (order, host) in hosts.enumerated() {
host.orderPosition = Int32(order)
}
try? viewContext.save()
And in delegate:
internal func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any,
at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?)
{
switch type {
case .delete:
hosts[indexPath!.row].stopTrack()
hosts.remove(at: indexPath!.row)
case .insert:
let hostViewModel = HostViewModel(host: frc.object(at: newIndexPath!))
hosts.insert(hostViewModel, at: newIndexPath!.row)
hostViewModel.startTrack()
case .update:
hosts[indexPath!.row].update(host: frc.object(at: indexPath!))
case .move:
hosts[indexPath!.row].update(host: frc.object(at: indexPath!))
hosts[newIndexPath!.row].update(host: frc.object(at: newIndexPath!))
default:
return
}
}
In my case (again, cannot explain why but it could maybe help someone)
I changed the ForEach(self.houses, id: \.id) { house in ...
into
ForEach(self.houses.indices, id: \.self) { i in
Text(self.houses[i].name)
.id(self.houses[i].id)
}

Passing data from enum to sheet triggered inside ForEach loop

I've got an ForEach loop inside my VStack, so that for every element in my enum a new "cell" is created. This works just fine. I can pass the title and the number for each cell, but in each cell there is a button which is toggling a sheet view. Each sheet should contain the according text in a scroll view. The text therefore is also given in the enum.
Problem: But when I'm trying to pass that infoText via the element.infoText for every sheet the infoText of the first element in the enum gets presented.
The ForEach loop:
struct ListView: View{
#State var infoSheetIsPresented: Bool = false
var body: some View{
VStack {
ForEach(WelcomeCardViewContent.allCases, id: \.self) {
element in
HStack {
Text(element.text)
Button(action: {
self.infoSheetIsPresented.toggle()
}) {
Image(systemName: "info.circle")
}
.sheet(isPresented: self.$infoSheetIsPresented) {
Text(element.infoText)
}
}
}
}
}
}
And here is my enum. Of course there's the InfoSheetView as well, but like i said its basically just a scroll view with text. The text gets passed with a simple "text" constant. For simplicity I've replaced the separate sheet view with a simple text view -> same problem.
enum WelcomeCardViewContent: String, CaseIterable{
case personalData
case bodyParameters
var text: String {
switch self{
case .personalData:
return "Personal Data"
case .bodyParameters:
return "Body Parameters"
}
}
var infoText: String {
switch self{
case .personalData:
return "1 Lorem ipsum dolor.."
case .bodyParameters:
return "2 Lorem ipsum dolor sit amet."
}
}
}
Thanks for your advice ^^.
Since you were losing track of the current card, I fixed this by saving the card which is going to be shown. Now the text is displayed correctly.
Here is the fixed version:
struct ListView: View {
#State private var infoSheetIsPresented: Bool = false
#State private var showingCard: WelcomeCardViewContent = .personalData
var body: some View {
ForEach(WelcomeCardViewContent.allCases, id: \.self) { element in
HStack {
Text(element.text)
Button(action: {
self.showingCard = element
self.infoSheetIsPresented.toggle()
}) {
Image(systemName: "info.circle")
}
.sheet(isPresented: self.$infoSheetIsPresented) {
Text(self.showingCard.infoText)
}
}
}
}
}

SwiftUI ForEach not correctly updating in scrollview

I have a SwiftUI ScrollView with an HStack and a ForEach inside of it. The ForEach is built off of a Published variable from an ObservableObject so that as items are added/removed/set it will automatically update in the view. However, I'm running into multiple problems:
If the array starts out empty and items are then added it will not show them.
If the array has some items in it I can add one item and it will show that, but adding more will not.
If I just have an HStack with a ForEach neither of the above problems occur. As soon as it's in a ScrollView I run into the problems.
Below is code that can be pasted into the Xcode SwiftUI Playground to demonstrate the problem. At the bottom you can uncomment/comment different lines to see the two different problems.
If you uncomment problem 1 and then click either of the buttons you'll see just the HStack updating, but not the HStack in the ScrollView even though you see init print statements for those items.
If you uncomment problem 2 and then click either of the buttons you should see that after a second click the the ScrollView updates, but if you keep on clicking it will not update - even though just the HStack will keep updating and init print statements are output for the ScrollView items.
import SwiftUI
import PlaygroundSupport
import Combine
final class Testing: ObservableObject {
#Published var items: [String] = []
init() {}
init(items: [String]) {
self.items = items
}
}
struct SVItem: View {
var value: String
init(value: String) {
print("init SVItem: \(value)")
self.value = value
}
var body: some View {
Text(value)
}
}
struct HSItem: View {
var value: String
init(value: String) {
print("init HSItem: \(value)")
self.value = value
}
var body: some View {
Text(value)
}
}
public struct PlaygroundRootView: View {
#EnvironmentObject var testing: Testing
public init() {}
public var body: some View {
VStack{
Text("ScrollView")
ScrollView(.horizontal) {
HStack() {
ForEach(self.testing.items, id: \.self) { value in
SVItem(value: value)
}
}
.background(Color.red)
}
.frame(height: 50)
.background(Color.blue)
Spacer()
Text("HStack")
HStack {
ForEach(self.testing.items, id: \.self) { value in
HSItem(value: value)
}
}
.frame(height: 30)
.background(Color.red)
Spacer()
Button(action: {
print("APPEND button")
self.testing.items.append("A")
}, label: { Text("APPEND ITEM") })
Spacer()
Button(action: {
print("SET button")
self.testing.items = ["A", "B", "C"]
}, label: { Text("SET ITEMS") })
Spacer()
}
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = UIHostingController(
// problem 1
rootView: PlaygroundRootView().environmentObject(Testing())
// problem 2
// rootView: PlaygroundRootView().environmentObject(Testing(items: ["1", "2", "3"]))
)
Is this a bug? Am I missing something? I'm new to iOS development..I did try wrapping the actual items setting/appending in the DispatchQueue.main.async, but that didn't do anything.
Also, maybe unrelated, but if you click the buttons enough the app seems to crash.
Just ran into the same issue. Solved with empty array check & invisible HStack
ScrollView(showsIndicators: false) {
ForEach(self.items, id: \.self) { _ in
RowItem()
}
if (self.items.count == 0) {
HStack{
Spacer()
}
}
}
It is known behaviour of ScrollView with observed empty containers - it needs something (initial content) to calculate initial size, so the following solves your code behaviour
#Published var items: [String] = [""]
In general, in such scenarios I prefer to store in array some "easy-detectable initial value", which is removed when first "real model value" appeared and added again, when last disappears. Hope this would be helpful.
For better readability and also because the answer didn't work for me. I'd suggest #TheLegend27 answer to be slightly modified like this:
if self.items.count != 0 {
ScrollView(showsIndicators: false) {
ForEach(self.items, id: \.self) { _ in
RowItem()
}
}
}

Resources