Handle a dynamic list in SwiftUI - swiftui

I have a NavigationView in SwiftUI where the left pane and right pane show a list of names. The left pane is selectable and each selection has its own list in the right hand pane. Both the left pane and right pane are getting their data from the same type being the class DataProvider. This is done for simplicity and not because that is a requirement.
There is a button in the UI that adds data to both the left pane and to the right hand pane for the first element of the left pane.
I am running this on a Mac and not on an iPhone or iPad.
The code looks like this:
//
// DemoApp.swift
// Shared
//
// Created by Fred Appelman on 08/06/2022.
//
import SwiftUI
#main
struct DemoApp: App {
var body: some Scene {
WindowGroup {
LeftPane()
}
}
}
class DataProvider: ObservableObject, Identifiable, Hashable {
static func == (lhs: DataProvider, rhs: DataProvider) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) { hasher.combine(id) }
let id = UUID()
#Published var name: String
#Published var data: [DataProvider]
init(name: String, data: [DataProvider]) {
self.name = name
self.data = data
}
}
struct RightPane: View {
#Binding var dataProvider: DataProvider?
var body: some View {
if let dataProvider = dataProvider {
List(dataProvider.data, id: \.self) { dataProvider in
Text(dataProvider.name)
}
} else {
Text("Select in left pane")
}
}
}
struct LeftPane: View {
#State var data: [DataProvider] = [
DataProvider(name: "left-a", data: [
DataProvider(name: "right-a1", data: []),
DataProvider(name: "right-a2", data: [])
]),
DataProvider(name: "left-b", data: [
DataProvider(name: "right-b1", data: []),
DataProvider(name: "right-b2", data: [])
]),
]
#State private var dataProvider: DataProvider?
var body: some View {
NavigationView {
List(data, id: \.self, selection: $dataProvider) { element in
Text(element.name)
}
RightPane(dataProvider: $dataProvider)
}
Button("Add extra data")
{
// Add to left pane
let extraDataLeftPane = DataProvider(name: "left-c", data: [
DataProvider(name: "right-c1",data: []),
DataProvider(name: "right-c2",data: [])
])
data.append(extraDataLeftPane)
// Add to right pane
data[0].data.append(DataProvider(name: "right-a3", data: []))
}
}
}
When this application is started the left pane elements can be selected and the right pane values are shown. So far so good.
Then select the top element in the left pane and push the button to add extra data.
This extra data shows immediately in the left pane but does not show in the right pane. It will only show if you click away in the left pane and then re-select the top entry.
So, what can I do to make the data show immediately in the right hand pane?

A #Binding doesn't know or care if your value is observable, it only cares if the value is changed. Since you're modifying a property of a reference type (DataProvider), you're not altering the value stored in the binding, so nothing is triggering a redraw.
It makes more sense to have an observed object in the right pane:
struct RightPane: View {
#ObservedObject var dataProvider: DataProvider
var body: some View {
List(dataProvider.data, id: \.self) { dataProvider in
Text(dataProvider.name)
}
}
}
And move the conditional back to the left pane:
NavigationView {
List(data, id: \.self, selection: $dataProvider) { element in
Text(element.name)
}
if let selection = dataProvider {
RightPane(dataProvider: selection)
} else {
Text("Select in left pane")
}
}
This only solves your current problem, though. The same thing is going to apply to the left pane if you mutate a property of an individual data provider in the array - there is no mutation of the array itself, so your state variable will not trigger a redraw. You should have a root data provider object stored as a #StateObject, and drive your left pane from the data property of that object.
You'll then want to use a specific view which takes and observes each individual provider, rather than a Text with a directly assigned string, so that changes are noticed. The alternative is that each data provider observes its children and sends object will change, but if you have a complex tree of these objects that is going to lead to a lot of unnecessary redraws.

Related

SwiftUI List selected item in composed List

I am building a File Manager app.
In this App the user can select a specific Folder in a list. There is also the possibility to select the option "All Folders" that is the first element in the list.
I want to keep the selected folder in a AppState class, setting it to nil if the user is selecting "All Folders":
class AppState: ObservableObject {
#Published var selectedFolder: Folder?
}
struct FileListView: View {
#EnvironmentObject
var appState: AppState
var body: some View {
List() {
FileRowView(
title: "All Files",
)
.onTapGesture {
appState.selectedFolder = nil
}
ForEach(filesList) {
file in
FileRowView(
title: file.title ?? "File unnamed",
)
.onTapGesture {
appState.selectedFolder = folder
}
}
}
}
}
Even though this code works and selectedFolder is updated, the List does not highlight in the View actual selected element. It does not have this "bluish" look and feel usually applied to selected items in a list.
You need the List version that keeps track of selections: List(selection: ).
Even though the binding selection var is optional, you cannot use nil for a List element. nil to the List means that nothing is selected.
With that List initializer you don't need onTapGesture, just give each row a .tag() of the same type as your selection var.
here is a simplified example:
struct FileListView: View {
#State var selectedFolder: Int?
var body: some View {
List(selection: $selectedFolder) {
Text("All Files")
.tag(999)
ForEach(0..<10) { file in
Text("file \(file)")
.tag(file)
}
}
}
}

SwiftUI List with #FocusState and focus change handling

I want to use a List, #FocusState to track focus, and .onChanged(of: focus) to ensure the currently focused field is visible with ScrollViewReader. The problem is: when everything is setup together the List rebuilds constantly during scrolling making the scrolling not as smooth as it needs to be.
I found out that the List rebuilds on scrolling when I attach .onChanged(of: focus). The issue is gone if I replace List with ScrollView, but I like appearance of List, I need sections support, and I need editing capabilities (e.g. delete, move items), so I need to stick to List view.
I used Self._printChanges() in order to see what makes the body to rebuild itself when scrolling and the output was like:
ContentView: _focus changed.
ContentView: _focus changed.
ContentView: _focus changed.
ContentView: _focus changed.
...
And nothing was printed from the closure attached to .onChanged(of: focus). Below is the simplified example, the smoothness of scrolling is not a problem in this example, however, once the List content is more or less complex the smooth scrolling goes away and this is really due to .onChanged(of: focus) :(
Question: Are there any chances to listen for focus changes and not provoke the List to rebuild itself on scrolling?
struct ContentView: View {
enum Field: Hashable {
case fieldId(Int)
}
#FocusState var focus: Field?
#State var text: String = ""
var body: some View {
List {
let _ = Self._printChanges()
ForEach(0..<100) {
TextField("Enter the text for \($0)", text: $text)
.id(Field.fieldId($0))
.focused($focus, equals: .fieldId($0))
}
}
.onChange(of: focus) { _ in
print("Not printed unless focused manually")
}
}
}
if you add printChanges to the beginning of the body, you can monitor the views and see that they are being rendered by SwiftUI (all of them on each focus lost and focus gained)
...
var body: some View {
let _ = Self._printChanges() // <<< ADD THIS TO SEE RE-RENDER
...
so after allot of testing, it seams that the problem is with .onChange, once you add it SwiftUI will redraw all the Textfields,
the only BYPASS i found is to keep using the deprecated API as it works perfectly, and renders only the two textfields (the one that lost focus, and the one that gained the focus),
so the code should look this:
struct ContentView: View {
enum Field: Hashable {
case fieldId(Int)
}
// #FocusState var focus: Field? /// NO NEED
#State var text: String = ""
var body: some View {
List {
let _ = Self._printChanges()
ForEach(0..<100) {
TextField("Enter the text for \($0)", text: $text)
.id(Field.fieldId($0))
// .focused($focus, equals: .fieldId($0)) /// NO NEED
}
}
// .onChange(of: focus) { _ in /// NO NEED
// print("Not printed unless focused manually") /// NO NEED
// } /// NO NEED
.focusable(true, onFocusChange: { focusNewValue in
print("Only textfileds that lost/gained focus will print this")
})
}
}
I recommend to consider separation of list row content into standalone view and use something like focus "selection" approach. Having FocusState internal of each row prevents parent view from unneeded updates (something like pre-"set up" I assume).
Tested with Xcode 13.4 / iOS 15.5
struct ContentView: View {
enum Field: Hashable {
case fieldId(Int)
}
#State private var inFocus: Field?
var body: some View {
List {
let _ = Self._printChanges()
ForEach(0..<100, id: \.self) {
ExtractedView(i: $0, inFocus: $inFocus)
}
}
.onChange(of: inFocus) { _ in
print("Not printed unless focused manually")
}
}
struct ExtractedView: View {
let i: Int
#Binding var inFocus: Field?
#State private var text: String = ""
#FocusState private var focus: Bool // << internal !!
var body: some View {
TextField("Enter the text for \(i)", text: $text)
.focused($focus)
.id(Field.fieldId(i))
.onChange(of: focus) { _ in
inFocus = .fieldId(i) // << report selection outside
}
}
}
}

SwiftUI Navigation popping back when modifying list binding property in a pushed view

When I update a binding property from an array in a pushed view 2+ layers down, the navigation pops back instantly after a change to the property.
Xcode 13.3 beta, iOS 15.
I created a simple demo and code is below.
Shopping Lists
List Edit
List section Edit
Updating the list title (one view deep) is fine, navigation stack stays same, and changes are published if I return. But when adjusting a section title (two deep) the navigation pops back as soon as I make a single change to the property.
I have a feeling I'm missing basic fundamentals here, and I have a feeling it must be related to the lists id? but I'm struggling to figure it out or work around it.
GIF
Code:
Models:
struct ShoppingList {
let id: String = UUID().uuidString
var title: String
var sections: [ShoppingListSection]
}
struct ShoppingListSection {
let id: String = UUID().uuidString
var title: String
}
View Model:
final class ShoppingListsViewModel: ObservableObject {
#Published var shoppingLists: [ShoppingList] = [
.init(
title: "Shopping List 01",
sections: [
.init(title: "Fresh food")
]
)
]
}
Content View:
struct ContentView: View {
var body: some View {
NavigationView {
ShoppingListsView()
}
}
}
ShoppingListsView
struct ShoppingListsView: View {
#StateObject private var viewModel = ShoppingListsViewModel()
var body: some View {
List($viewModel.shoppingLists, id: \.id) { $shoppingList in
NavigationLink(destination: ShoppingListEditView(shoppingList: $shoppingList)) {
Text(shoppingList.title)
}
}
.navigationBarTitle("Shopping Lists")
}
}
ShoppingListEditView
struct ShoppingListEditView: View {
#Binding var shoppingList: ShoppingList
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("Title", text: $shoppingList.title)
}
Section(header: Text("Sections")) {
List($shoppingList.sections, id: \.id) { $section in
NavigationLink(destination: ShoppingListSectionEditView(section: $section)) {
Text(section.title)
}
}
}
}
.navigationBarTitle("Edit list")
}
}
ShoppingListSectionEditView
struct ShoppingListSectionEditView: View {
#Binding var section: ShoppingListSection
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("title", text: $section.title)
}
}
.navigationBarTitle("Edit section")
}
}
try this, works for me:
struct ContentView: View {
var body: some View {
NavigationView {
ShoppingListsView()
}.navigationViewStyle(.stack) // <--- here
}
}
Try to make you object confirm to Identifiable and return value which unique and stable, for your case is ShoppingList.
Detail view seems will pop when object id changed.
The reason your stack is popping back to the root ShoppingListsView is that the change in the list is published and the root ShoppingListsView is registered to listen for updates to the #StateObject.
Therefore, any change to the list is listened to by ShoppingListsView, causing that view to be re-rendered and for all new views on the stack to be popped in order to render the root ShoppingListsView, which is listening for updates on the #StateObject.
The solution to this is to change the #StateObject to #EnvironmentObject
Please refactor your code to change ShoppingListsViewModel to use an #EnvironmentObject wrapper instead of a #StateObject wrapper
You may pass the environment object in to all your child views and also add a boolean #Published flag to track any updates to the data.
Then your ShoppingListView would look as below
struct ShoppingListsView: View {
#EnvironmentObject var viewModel = ShoppingListsViewModel()
var body: some View {
List($viewModel.shoppingLists, id: \.id) { $shoppingList in
NavigationLink(destination: ShoppingListEditView(shoppingList: $shoppingList)) {
Text(shoppingList.title)
}
}
.navigationBarTitle("Shopping Lists")
}
}
Don't forget to pass the viewModel in to all your child views.
That should fix your problem.

Get selected item in SwiftUI list without using a navigation link

I'm writing a SwiftUI Mac app that is similar to a kanban board. The app has three lists: Todo, Doing, and Done. At the bottom of each list is a button to move a task to another list. For example the todo list has a Start Doing button. Selecting a task from the todo list and clicking the button should move the task from the todo list to the doing list.
Every SwiftUI list selection example I have seen uses a navigation link. Selecting a list item takes you to another view. But I don't want to want to navigate to another view when selecting a list item. I want the selected task so I can change its status and move it to the correct list when clicking the button.
Here's the code for one of my lists.
struct TodoList: View {
// The board has an array of tasks.
#Binding var board: KanbanBoard
#State private var selection: Task? = nil
#State private var showAddSheet = false
var body: some View {
VStack {
Text("Todo")
.font(.title)
List(todoTasks, selection: $selection) { task in
Text(task.title)
}
HStack {
Button(action: { showAddSheet = true }, label: {
Label("Add", systemImage: "plus.square")
})
Spacer()
Button(action: { selection?.status = .doing}, label: {
Label("Start Doing", systemImage: "play.circle")
})
}
}
.sheet(isPresented: $showAddSheet) {
AddTaskView(board: $board)
}
}
var todoTasks: [Task] {
// Task conforms to Identifiable.
// A task has a status that is an enum: todo, doing, or done.
return board.tasks.filter { $0.status == .todo}
}
}
When I click on a list item, it is not selected.
How do I get the selected item from the list without using a navigation link?
Workaround
Tamas Sengel's answer led me to a workaround. Give each list item a Start Doing button so I don't have to track the selection.
List(todoTasks, id: \.self) { task in
HStack {
Text(task.title)
Button {
task.status = .doing
} label: {
Text("Start Doing")
}
}
}
The workaround helps for my specific case. But I'm going to keep the question open in hopes of an answer that provides a better alternative to using a button for people who want a way to get the selected list item.
Use a Button in the List and in the action, set a #State variable to the current list item.
#State var currentTask: Task?
List(todoTasks, id: \.self) { task in
Button {
currentTask = task
} label: {
Text(task.title)
}
}
Use .environment(\.editMode, .constant(.active)) to turn on selecting capability.
import SwiftUI
struct ContentView: View {
struct Ocean: Identifiable, Hashable {
let name: String
let id = UUID()
}
private var oceans = [
Ocean(name: "Pacific"),
Ocean(name: "Atlantic"),
Ocean(name: "Indian"),
Ocean(name: "Southern"),
Ocean(name: "Arctic")
]
#State private var multiSelection = Set<UUID>()
var body: some View {
NavigationView {
List(oceans, selection: $multiSelection) {
Text($0.name)
}
.navigationTitle("Oceans")
.environment(\.editMode, .constant(.active))
.onTapGesture {
// This is a walk-around: try how it works without `asyncAfter()`
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: {
print(multiSelection)
})
}
}
Text("\(multiSelection.count) selections")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Put your 3 List with same data array but filtering by status on each one something like:
task.filter({ $0.status == .toDo })
Then on your row add the modifier .onTapGesture be sure to cover all the available space.
Inside the code block introduce your logic or func to change the item status. changeTaskStatus(item: task)

SwiftUI Edit Struct from List

I am attempting to have a list that when a cell it tapped it changes the hasBeenSeen Bool value within the State object itself.
struct State: Identifiable {
var id = UUID()
let name: String
var hasBeenSeen: Bool = false
}
struct ContentView: View {
let states: [State] = [
State(name: "Oregon", hasBeenSeen: true),
State(name: "California", hasBeenSeen: true),
State(name: "Massachussets", hasBeenSeen: false),
State(name: "Washington", hasBeenSeen: true),
State(name: "Georgia", hasBeenSeen: false)
]
var body: some View {
NavigationView {
List {
ForEach(states, id: \.id) { state in
StateCell(state: state)
}
}.navigationBarTitle(Text("States"))
}
}
}
struct StateCell: View {
var state: State
var body: some View {
HStack {
Text(state.name)
Spacer()
if state.hasBeenSeen {
Image(systemName: "eye.fill")
}
}.onTapGesture {
// state.hasBeenSeen.toggle()
}
}
}
My original thought is that I need to make hasBeenSeen to a #State var but that doesn't seem to work. How can I make this Bool val editable from a list?
Views in SwiftUI are immutable - they are just structures - so you can't change their properties. That's why SwiftUI has a concept of a #State property wrapper. When you change "state" property, SwiftUI actually updates the state value, not the view's property value (which is, again, immutable).
So, you need to set #State on the states property within your view. (You'd also need to change the name, since identifier State is already taken by the State property wrapper - so I just changed it to StateEntity)
#State var states: [StateEntity] = [
StateEntity(name: "Oregon", hasBeenSeen: true),
// ... etc
]
That's not enough, though, since when you pass an element of the states array (a StateEntity value) to a child view, you're just passing a copy.
For that, you'd need a binding. A binding allows child views to modify state properties of parent views, without owning the data. So, the child view's property should use the #Binding property wrapper:
struct StateCell: View {
#Binding var state: StateEntity
// ...
}
SwiftUI made it easy to get the binding of state property by using the projected value, which in this case is $states.
However, you need to pass a binding not to the entire array, but to a specific element of that array. That, unfortunately (and rather annoyingly), is a bit trickier. You need to get the index of the element, and given the index, access the binding like so: $state[index].
One way is to do a ForEach over indices of states:
var body: some View {
NavigationView {
List {
ForEach(states.indices) { index in
StateCell(state: self.$states[index])
}
}.navigationBarTitle(Text("States"))
}
}