SwiftUI Reorder list dynamic sections from another view - swift

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)
}
}

Related

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
}
}

Selection in SwiftUI NavigationView lost if List order changes

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")
}
}
}

How to get SwiftUI Picker in subview working? (Greyed out)

I'm working on a SwiftUI project and am having trouble getting a picker to work correctly.
I've got a hierarchy of views split into multiple files with the initial view wrapping everything in a NavigationView.
Looks something like this:
MainFile (TabView -> NavigationView)
- ListPage (NavigationLink)
-- DetailHostPage (Group.EditButton)
if editing
--- DetailViewPage
else
--- DetailEditPage (picker in a form)
The picker that I have in the DetailEditPage does not let me change it's value, though it does display the correct current value.
Picker(selection: self.$_myObj.SelectedEnum, label: Text("Type")) {
ForEach(MyEnum.allCases, id: \.self) {
Text("\(String(describing: $0))")
}
}
If I wrap the picker in a navigation view directly then it works, but now I have a nested navigation view resulting in two back buttons, which is not what I want.
What is causing the picker not to allow it's selection to change, and how can I get it working?
EDIT
Here's an example of how to replicate this:
ContentView.swift
class MyObject: ObservableObject {
#Published var enumValue: MyEnum
init(enumValue: MyEnum) {
self.enumValue = enumValue
}
}
enum MyEnum: CaseIterable {
case a, b, c, d
}
struct ContentView: View {
#State private var objectList = [MyObject(enumValue: .a), MyObject(enumValue: .b)]
var body: some View {
NavigationView {
List {
ForEach(0..<objectList.count) { index in
NavigationLink(destination: Subview(myObject: self.$objectList[index])) {
Text("Object \(String(index))")
}
}
}
}
}
}
struct Subview: View {
#Environment(\.editMode) var mode
#Binding var myObject: MyObject
var body: some View {
HStack {
if mode?.wrappedValue == .inactive {
//The picker in this view shows
SubViewShow(myObject: self.$myObject)
} else {
//The picker in this view does not
SubViewEdit(myObject: self.$myObject)
}
}.navigationBarItems(trailing: EditButton())
}
}
struct SubViewShow: View {
#Binding var myObject: MyObject
var body: some View {
Form {
Picker(selection: self.$myObject.enumValue, label: Text("enum values viewing")) {
ForEach(MyEnum.allCases, id: \.self) {
Text("\(String(describing: $0))")
}
}
}
}
}
struct SubViewEdit: View {
#Binding var myObject: MyObject
var body: some View {
Form {
Picker(selection: self.$myObject.enumValue, label: Text("enum values editing")) {
ForEach(MyEnum.allCases, id: \.self) {
Text("\(String(describing: $0))")
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
We don't have an idea about your enum and model (_myObj) implementation.
In the next snippet is working code (copy - paste - test it) where you can see how to implement picker with enum. You can even uncomment the lines where Form is declared, if you like to have your Picker in Form
import SwiftUI
struct Subview: View {
#Binding var flag: Bool
#Binding var sel: Int
var body: some View {
VStack {
Text(String(describing: MyEnum.allCases()[sel]))
Button(action: {
self.flag.toggle()
}) {
Text("toggle")
}
if flag {
FlagOnView()
} else {
FlagOffView(sel: $sel)
}
}
}
}
enum MyEnum {
case a, b, c, d
static func allCases()->[MyEnum] {
[MyEnum.a, MyEnum.b, MyEnum.c, MyEnum.d]
}
}
struct FlagOnView: View {
var body: some View {
Text("flag on")
}
}
struct FlagOffView: View {
#Binding var sel: Int
var body: some View {
//Form {
Picker(selection: $sel, label: Text("select")) {
ForEach(0 ..< MyEnum.allCases().count) { (i) in
Text(String(describing: MyEnum.allCases()[i])).tag(i)
}
}.pickerStyle(WheelPickerStyle())
//}
}
}
struct ContentView: View {
#State var sel: Int = 0
#State var flag = false
var body: some View {
NavigationView {
List {
NavigationLink(destination: Subview(flag: $flag, sel: $sel)) {
Text("push to subview")
}
NavigationLink(destination: Text("S")) {
Text("S")
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
UPDATE change your code
struct SubViewShow: View {
#Binding var myObject: MyObject
#State var sel = Set<Int>()
var body: some View {
Form {
List(selection: $sel) {
ForEach(MyEnum.allCases, id: \.self) {
Text("\(String(describing: $0))")
}.onDelete { (idxs) in
print("delete", idxs)
}
}
Picker(selection: self.$myObject.enumValue, label: Text("enum values viewing")) {
ForEach(MyEnum.allCases, id: \.self) {
Text("\(String(describing: $0))")
}
}.pickerStyle(SegmentedPickerStyle())
}
}
}
struct SubViewEdit: View {
#Binding var myObject: MyObject
#State var sel = Set<Int>()
var body: some View {
Form {
List(selection: $sel) {
ForEach(MyEnum.allCases, id: \.self) {
Text("\(String(describing: $0))")
}.onDelete { (idxs) in
print("delete", idxs)
}
}
Picker(selection: self.$myObject.enumValue, label: Text("enum values editing")) {
ForEach(MyEnum.allCases, id: \.self) {
Text("\(String(describing: $0))")
}
}
.pickerStyle(SegmentedPickerStyle())
}
}
}
and see what happens
I think, you just misunderstood what the editing mode is for.

SwiftUI: Update NavigationView after deletion (iPad)

I want to show the empty view (here: Text("Please select a person.")) after the deletion of a row has happend on an iPad.
Currently: The detail view on an iPad will not get updated after the deletion of an item.
Expected: Show the empty view after the selected item gets deleted.
struct DetailView: View {
var name: String
var body: some View {
Text("Detail of \(name)")
}
}
struct MainView: View {
#State private var users = ["Paul", "Taylor", "Adele"]
var body: some View {
NavigationView {
List {
ForEach(users, id: \.self) { user in
NavigationLink(destination: DetailView(name: user)) {
Text(user)
}
}
.onDelete(perform: delete)
}
Text("Please select a person.")
}
}
func delete(at offsets: IndexSet) {
users.remove(atOffsets: offsets)
}
}
NavigationView example from Hacking With Swift.
In the example below, the detail view is shown correctly after the first launch: here
But after the deletion of a row, the previously selected detail view (here: Paul) is still shown: here
As of iOS 14, deleting an element from a list in a navigation view does not seem to be supported.
The NavigationLink type takes an isActive binding, but that does not work in the case of deletion. When you receive the .onDelete callback it is too late. That NavigationLink is not in the list anymore and any change to the binding you passed to it is not going to have any effect.
The workaround is to pass a binding to the DetailView with all the elements, so that it can verify if an element is present and display some content accordingly.
struct DetailView: View {
var name: String
#Binding var users: [String]
var body: some View {
if users.contains(name) {
Text("Detail of \(name)")
} else {
EmptyView()
}
}
}
struct MainView: View {
#State private var users = ["Paul", "Taylor", "Adele"]
var body: some View {
NavigationView {
List {
ForEach(users, id: \.self) { user in
NavigationLink(destination: DetailView(name: user, users: $users)) {
Text(user)
}
}
.onDelete(perform: delete)
}
}
}
func delete(at offsets: IndexSet) {
users.remove(atOffsets: offsets)
}
}
You can use a binding from parent view to manually trigger re-rendering (otherwise the child view won't get notified):
import SwiftUI
struct DetailView: View {
var name: String
#Binding var notifier: Int
#State var deleted: Bool = false
var body: some View {
Group {
if !deleted {
Text("Detail of \(name)")
.onChange(of: notifier, perform: { _ in deleted = true })
} else {
Text("Deleted")
}
}
}
}
struct MainView: View {
#State private var users = ["Paul", "Taylor", "Adele"]
#State private var deleteNotifier: Int = 0
var body: some View {
NavigationView {
List {
ForEach(users, id: \.self) { user in
NavigationLink(destination: DetailView(name: user,
notifier: $deleteNotifier)) {
Text(user)
}
}
.onDelete(perform: delete)
}
Text("Please select a person.")
}
}
func delete(at offsets: IndexSet) {
users.remove(atOffsets: offsets)
deleteNotifier += 1
}
}
You can use the second choice for the list updating: index. That way it will reinforce the refreshing:
var body: some View {
NavigationView {
List {
ForEach(users.indices, id: \.self) { index in
NavigationLink(destination: DetailView(name: self.users[index])) {
Text(self.users[index])
}
}
.onDelete(perform: delete)
}
Text("Please select a person.")
}
}