Selection in SwiftUI NavigationView lost if List order changes - swift

This is the test data model:
class Item: Identifiable {
let name: String
init( n: Int) {
self.name = "\(n)"
}
}
class Storage: ObservableObject {
#Published var items = [Item( n: 1), Item( n: 2)]
func reverse() {
items = self.items.reversed()
}
}
This is my content view, with a NavigationLink and a detail view with a button that reverses the item order:
struct ContentView: View {
#ObservedObject
var storage = Storage()
var body: some View {
NavigationView {
List {
ForEach( storage.items) { item in
NavigationLink( destination: Button( action: {
self.storage.reverse()
}) {
Text("Reverse")
}) {
Text( item.name).padding()
}
}
}
}
}
}
Now if I tap on Reverse the NavigationView or List seems to lose its selection, pops the view, and pushes it again:
Is this expected behaviour or a bug in SwiftUI? Is there a workaround? I would expect that the detail view simply stays as it is, without reloading.

You need to specify an explicit id for your ForEach loop.
If you use a static ForEach (without the id parameter) your view is rebuilt because the data (storage.items) is changed.
Try the following:
struct ContentView: View {
#ObservedObject
var storage = Storage()
var body: some View {
NavigationView {
List {
ForEach(storage.items, id:\.name) { item in // <- add `id` parameter
NavigationLink(destination: self.destinationView) {
Text(item.name).padding()
}
}
}
}
}
var destinationView: some View {
Button(action: {
self.storage.reverse()
}) {
Text("Reverse")
}
}
}
This method, however, only works if the original position of selected item is maintained.
In this example performing the update() from the detail screen for item 1 will not pop the NavigationLink.
class Storage: ObservableObject {
#Published var items = [Item(n: 1), Item(n: 2)]
func update() {
items = [Item(n: 1), Item(n: 3)]
}
}
Here is a workaround to make it work (use an empty NavigationLink):
struct ContentView: View {
#ObservedObject var storage = Storage()
#State var isLinkActive = false
var body: some View {
NavigationView {
VStack {
List {
ForEach(storage.items, id:\.name) { item in
Button(action: {
self.isLinkActive = true
}) {
Text(item.name).padding()
}
}
}
NavigationLink(destination: self.destinationView, isActive: $isLinkActive) {
EmptyView()
}
}
}
}
var destinationView: some View {
Button(action: {
self.storage.reverse()
}) {
Text("Reverse")
}
}
}

Related

SwiftUI: How to update element in ForEach without necessity to update all elements?

Imagine that you have some parent view that generate some number of child views:
struct CustomParent: View {
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index)
}
}
}
}
struct CustomChild: View {
#State var index: Int
#State private var text: String = ""
var body: some View {
Button(action: {
// Here should be some update of background/text/opacity or whatever.
// So how can I update background/text/opacity or whatever for button with index for example 3 from button with index for example 1?
}) {
Text(text)
}
.onAppear {
text = String(index)
}
}
}
Question is included in the code as comment.
Thanks!
UPDATE:
First of all really thanks for all of your answers, but now imagine that you use mentioned advanced approach.
struct CustomParent: View {
#StateObject var customViewModel = CustomViewModel()
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index, customViewModel: customViewModel)
}
}
}
}
If I use let _ = Self._printChanges() method in CustomChildView, to catch UI updates/changes, it'll print that every element in ForEach was updated/changed on button action.
struct CustomChild: View {
let index: Int
#ObservedObject var customViewModel: CustomViewModel
var body: some View {
let _ = Self._printChanges() // This have been added to code
Button(action: {
customViewModel.buttonPushed(at: index)
}) {
Text(customViewModel.childTexts[index])
}
}
}
class CustomViewModel: ObservableObject {
#Published var childTexts = [String](repeating: "", count: 10)
init() {
for i in 0..<childTexts.count {
childTexts[i] = String(i)
}
}
func buttonPushed(at index: Int) {
//button behaviors goes here
//for example:
childTexts[index + 1] = "A"
}
}
And now imagine that you have for example 1000 custom elements which have some background, opacity, shadow, texts, fonts and so on. Now I change text in any of the elements.
Based on log from let _ = Self._printChanges() method, it goes through all elements, and all elements are updated/changed what can cause delay.
Q1: Why did update/change all elements, if I change text in only one element?
Q2: How can I prevent update/change all elements, if I change only one?
Q3: How to update element in ForEach without necessity to update all elements?
Simpler Approach:
Although child views cannot access things that the host views have, it's possible to declare the child states in the host view and pass that state as a binding variable to the child view. In the code below, I have passed the childTexts variable to the child view, and (for your convenience) initialized the text so that it binds to the original element in the array (so that your onAppear works properly). Every change performed on the text and childTexts variable inside the child view reflects on the host view.
I strongly suggest not to do this though, as more elegant approaches exist.
struct CustomParent: View {
#State var childTexts = [String](repeating: "", count: 10)
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index, childTexts: $childTexts)
}
}
}
}
struct CustomChild: View {
let index: Int
#Binding private var text: String
#Binding private var childTexts: [String]
init(index: Int, childTexts: Binding<[String]>) {
self.index = index
self._childTexts = childTexts
self._text = childTexts[index]
}
var body: some View {
Button(action: {
//button behaviors goes here
//for example
childTexts[index + 1] = "A"
}) {
Text(text)
}
.onAppear {
text = String(index)
}
}
}
Advanced Approach:
By using the Combine framework, all your logics can be moved into an ObservableObject view model. This is much better as the button logic is no longer inside the view. In simplest terms, the #Published variable in the ObservableObject will publish a change when it senses its own mutation, while the #StateObjectand the #ObservedObject will listen and recalculate the view for you.
struct CustomParent: View {
#StateObject var customViewModel = CustomViewModel()
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index, customViewModel: customViewModel)
}
}
}
}
struct CustomChild: View {
let index: Int
#ObservedObject var customViewModel: CustomViewModel
var body: some View {
Button(action: {
customViewModel.buttonPushed(at: index)
}) {
Text(customViewModel.childTexts[index])
}
}
}
class CustomViewModel: ObservableObject {
#Published var childTexts = [String](repeating: "", count: 10)
init() {
for i in 0..<childTexts.count {
childTexts[i] = String(i)
}
}
func buttonPushed(at index: Int) {
//button behaviors goes here
//for example:
childTexts[index + 1] = "A"
}
}

How to update an element of an array in an Observable Object

Sorry if my question is silly, I am a beginner to programming. I have a Navigation Link to a detail view from a List produced from my view model's array. In the detail view, I want to be able to mutate one of the tapped-on element's properties, but I can't seem to figure out how to do this. I don't think I explained that very well, so here is the code.
// model
struct Activity: Identifiable {
var id = UUID()
var name: String
var completeDescription: String
var completions: Int = 0
}
// view model
class ActivityViewModel: ObservableObject {
#Published var activities: [Activity] = []
}
// view
struct ActivityView: View {
#StateObject var viewModel = ActivityViewModel()
#State private var showingAddEditActivityView = false
var body: some View {
NavigationView {
VStack {
List {
ForEach(viewModel.activities, id: \.id) {
activity in
NavigationLink(destination: ActivityDetailView(activity: activity, viewModel: self.viewModel)) {
HStack {
VStack {
Text(activity.name)
Text(activity.miniDescription)
}
Text("\(activity.completions)")
}
}
}
}
}
.navigationBarItems(trailing: Button("Add new"){
self.showingAddEditActivityView.toggle()
})
.navigationTitle(Text("Activity List"))
}
.sheet(isPresented: $showingAddEditActivityView) {
AddEditActivityView(copyViewModel: self.viewModel)
}
}
}
// detail view
struct ActivityDetailView: View {
#State var activity: Activity
#ObservedObject var viewModel: ActivityViewModel
var body: some View {
VStack {
Text("Number of times completed: \(activity.completions)")
Button("Increment completion count"){
activity.completions += 1
updateCompletionCount()
}
Text("\(activity.completeDescription)")
}
}
func updateCompletionCount() {
var tempActivity = viewModel.activities.first{ activity in activity.id == self.activity.id
}!
tempActivity.completions += 1
}
}
// Add new activity view (doesn't have anything to do with question)
struct AddEditActivityView: View {
#ObservedObject var copyViewModel : ActivityViewModel
#State private var activityName: String = ""
#State private var description: String = ""
var body: some View {
VStack {
TextField("Enter an activity", text: $activityName)
TextField("Enter an activity description", text: $description)
Button("Save"){
// I want this to be outside of my view
saveActivity()
}
}
}
func saveActivity() {
copyViewModel.activities.append(Activity(name: self.activityName, completeDescription: self.description))
print(copyViewModel.activities)
}
}
In the detail view, I am trying to update the completion count of that specific activity, and have it update my view model. The method I tried above probably doesn't make sense and obviously doesn't work. I've just left it to show what I tried.
Thanks for any assistance or insight.
The problem is here:
struct ActivityDetailView: View {
#State var activity: Activity
...
This needs to be a #Binding in order for changes to be reflected back in the parent view. There's also no need to pass in the entire viewModel in - once you have the #Binding, you can get rid of it.
// detail view
struct ActivityDetailView: View {
#Binding var activity: Activity /// here!
var body: some View {
VStack {
Text("Number of times completed: \(activity.completions)")
Button("Increment completion count"){
activity.completions += 1
}
Text("\(activity.completeDescription)")
}
}
}
But how do you get the Binding? If you're using iOS 15, you can directly loop over $viewModel.activities:
/// here!
ForEach($viewModel.activities, id: \.id) { $activity in
NavigationLink(destination: ActivityDetailView(activity: $activity)) {
HStack {
VStack {
Text(activity.name)
Text(activity.miniDescription)
}
Text("\(activity.completions)")
}
}
}
And for iOS 14 or below, you'll need to loop over indices instead. But it works.
/// from https://stackoverflow.com/a/66944424/14351818
ForEach(Array(zip(viewModel.activities.indices, viewModel.activities)), id: \.1.id) { (index, activity) in
NavigationLink(destination: ActivityDetailView(activity: $viewModel.activities[index])) {
HStack {
VStack {
Text(activity.name)
Text(activity.miniDescription)
}
Text("\(activity.completions)")
}
}
}
You are changing and increment the value of tempActivity so it will not affect the main array or data source.
You can add one update function inside the view model and call from view.
The view model is responsible for this updation.
class ActivityViewModel: ObservableObject {
#Published var activities: [Activity] = []
func updateCompletionCount(for id: UUID) {
if let index = activities.firstIndex(where: {$0.id == id}) {
self.activities[index].completions += 1
}
}
}
struct ActivityDetailView: View {
var activity: Activity
var viewModel: ActivityViewModel
var body: some View {
VStack {
Text("Number of times completed: \(activity.completions)")
Button("Increment completion count"){
updateCompletionCount()
}
Text("\(activity.completeDescription)")
}
}
func updateCompletionCount() {
self.viewModel.updateCompletionCount(for: activity.id)
}
}
Not needed #State or #ObservedObject for details view if don't have further action.

swiftui: detail view uses false List style (that of its parent) before it switches to the correct style (with delay)

I have a master-detail view with three levels. At the first level, a person is selected. At the second level, the persons' properties are shown using a grouped list with list style "InsetGroupedListStyle()"
My problem is: each time, the third level (here called "DetailView()") is displayed, it is displayed with the wrong style (the style of its parent), before it switches to the correct style with some delay.
This is a bad user experience. Any ideas?
Thanks!
import SwiftUI
struct Person: Identifiable, Hashable {
let id: Int
let name: String
}
class Data : ObservableObject {
#Published var persons: [Person] = [
Person(id: 0, name: "Alice"),
Person(id: 1, name: "Bob"),
]
}
#main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#StateObject var data = Data()
var body: some View {
NavigationView {
List {
ForEach(data.persons, id: \.self) { person in
NavigationLink(
destination: EditPerson(data: data, psId: person.id),
label: {
Text(person.name)
})
}
}.environment(\.defaultMinListRowHeight, 10)
.navigationTitle("Persons")
}
}
}
struct EditPerson: View {
#ObservedObject var data: Data
var psId: Int
var body: some View {
List() {
Section(header:
Text("HEADER1 ")
) {
NavigationLink(
destination: DetailView(data: data),
label: {
Text("1st link")
}
)
}
}.navigationTitle("Person #" + String(psId))
.listStyle(InsetGroupedListStyle()) // <--- the style specified here
// is preliminarily used for the DetailView, too.
}
}
struct DetailView: View {
#ObservedObject var data : Data
var body: some View {
List { // <- this list is displayed with grouped list style before
// it is updated some split seconds later
Button(action: {
print("button1 pressed")
}) {
Text("Button1")
}
Button(action: {
print("button2 pressed")
}) {
Text("Button2")
}
}
}
}
It seems to work if you simply explicitly set it back to PlainListStyle in DetailView():
struct DetailView: View {
#ObservedObject var data : Data
var body: some View {
List { // <- this list is displayed with grouped list style before
// it is updated some split seconds later
Button(action: {
print("button1 pressed")
}) {
Text("Button1")
}
Button(action: {
print("button2 pressed")
}) {
Text("Button2")
}
}
.listStyle(PlainListStyle()) // Explicitly set it here
}
}

SwiftUI Reorder list dynamic sections from another view

I have a simple List with sections that are stored inside an ObservableObject. I'd like to reorder them from another view.
This is my code:
class ViewModel: ObservableObject {
#Published var sections = ["S1", "S2", "S3", "S4"]
func move(from source: IndexSet, to destination: Int) {
sections.move(fromOffsets: source, toOffset: destination)
}
}
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
#State var showOrderingView = false
var body: some View {
VStack {
Button("Reorder sections") {
self.showOrderingView = true
}
list
}
.sheet(isPresented: $showOrderingView) {
OrderingView(viewModel: self.viewModel)
}
}
var list: some View {
List {
ForEach(viewModel.sections, id: \.self) { section in
Section(header: Text(section)) {
ForEach(0 ..< 3, id: \.self) { _ in
Text("Item")
}
}
}
}
}
}
struct OrderingView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
NavigationView {
List {
ForEach(viewModel.sections, id: \.self) { section in
Text(section)
}
.onMove(perform: viewModel.move)
}
.navigationBarItems(trailing: EditButton())
}
}
}
But in the OrderingView when trying to move sections I'm getting this error: "Attempt to create two animations for cell". Likely it's because the order of the sections has changed.
How can I change the order of the sections?
The problem of this scenario is recreated many times ViewModel, so modifications made in sheet just lost. (The strange thing is that in SwiftUI 2.0 with StateObject these changes also lost and EditButton does not work at all.)
Anyway. It looks like here is a found workaround. The idea is to break interview dependency (binding) and work with pure data passing them explicitly into sheet and return them back explicitly from it.
Tested & worked with Xcode 12 / iOS 14, but I tried to avoid using SwiftUI 2.0 features.
class ViewModel: ObservableObject {
#Published var sections = ["S1", "S2", "S3", "S4"]
func move(from source: IndexSet, to destination: Int) {
sections.move(fromOffsets: source, toOffset: destination)
}
}
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
#State var showOrderingView = false
var body: some View {
VStack {
Button("Reorder sections") {
self.showOrderingView = true
}
list
}
.sheet(isPresented: $showOrderingView) {
OrderingView(sections: viewModel.sections) {
self.viewModel.sections = $0
}
}
}
var list: some View {
List {
ForEach(viewModel.sections, id: \.self) { section in
Section(header: Text(section)) {
ForEach(0 ..< 3, id: \.self) { _ in
Text("Item")
}
}
}
}
}
}
struct OrderingView: View {
#State private var sections: [String]
let callback: ([String]) -> ()
init(sections: [String], callback: #escaping ([String]) -> ())
{
self._sections = State(initialValue: sections)
self.callback = callback
}
var body: some View {
NavigationView {
List {
ForEach(sections, id: \.self) { section in
Text(section)
}
.onMove {
self.sections.move(fromOffsets: $0, toOffset: $1)
}
}
.navigationBarItems(trailing: EditButton())
}
.onDisappear {
self.callback(self.sections)
}
}
}
A possible workaround solution for SwiftUI 1.0
I found a workaround to disable animations for the List by adding .id(UUID()):
var list: some View {
List {
...
}
.id(UUID())
}
This, however, messes the transition animations for NavigationLinks created with NavigationLink(destination:tag:selection:): Transition animation gone when presenting a NavigationLink in SwiftUI.
And all other animations (like onDelete) are missing as well.
The even more hacky solution is to disable list animations conditionally:
class ViewModel: ObservableObject {
...
#Published var isReorderingSections = false
...
}
struct OrderingView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
NavigationView {
...
}
.onAppear {
self.viewModel.isReorderingSections = true
}
.onDisappear {
self.viewModel.isReorderingSections = false
}
}
}
struct ContentView: View {
...
var list: some View {
List {
...
}
.id(viewModel.isReorderingSections ? UUID().hashValue : 1)
}
}

Dynamic NavigationLink destination in SwiftUI?

I have a View 'B' that has an initialiser that takes an argument.
struct B: View {
let arg: Int
init(arg: Int) {
self.arg = arg
}
var body: some View {
Text("\(arg)")
}
}
And I have a Navigation View 'A'.
'A' has one button, which when pressed, shows a popup where the user picks a number from 1-5. A closure of type (Int) -> Void is called with the chosen number.
struct A: View {
#State var showPicker = false
var body: some View {
NavigationView {
Button(action: { self.showPicker = true }) {
Text("Pick Number")
}
.sheet(isPresented: self.$showPicker, content: {
NumberPicker { number in
*** Possible to navigate to B from here? ***
}
})
}
}
}
Question
Is it possible to initialise view B with the result from the closure, and then navigate to it?
This used to be possible with DynamicNavigationDestinationLink, however Apple deprecated it and stated in the release notes that NavigationLink contains its capabilities now. I have searched through the docs, however, I have not been able to figure out how to use NavigationLink to produce the same outcome.
You need to add a new view with another navigationview where you can pick the number.
Here's a full code example how i would approach this:
import SwiftUI
struct viewA: View {
var body: some View {
NavigationView {
NavigationLink(destination: viewC()) {
Text("Pick Number")
}
}
}
}
struct viewB: View {
#Binding var showSheet: Bool
#Binding var arg: Int
var body: some View {
NavigationView {
List {
ForEach((1...5), id: \.self) {number in
Button(action: {
self.arg = number
self.showSheet.toggle()
}) {
Text("\(number)")
}
}
}
}
}
}
struct viewC: View {
#State var showSheet:Bool = true
#State var arg: Int = 0
var body: some View {
NavigationView {
Text("\(arg)")
}
.sheet(isPresented: $showSheet) {
viewB(showSheet: self.$showSheet, arg: self.$arg)
}
}
}