SwiftUI List selected item in composed List - swiftui

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

Related

SwiftUI header like datepicker above List

I want to create a List view with SwiftUI with a sticky component on the top that is outside of the List.
An example of what i want to achieve is on the bottom.
I tried placing the component inside the List as first item but it will be put inside a list item. and just hides a big part of the component
var body: some View {
List {
WeekHeader()
.environmentObject(weekStore)
DayView(
date: weekStore.currentDate,
showAddingWorkout: $showAddingWorkout,
isEditing: isEditing
)
}
.listStyle(.insetGrouped)
}
I tried placing a List inside a VStack with the component before the List which just splits the view in 2 with a List really small on the bottom.
var body: some View {
VStack {
WeekHeader()
.environmentObject(weekStore)
List {
DayView(
date: weekStore.currentDate,
showAddingWorkout: $showAddingWorkout,
isEditing: isEditing
)
}
.listStyle(.insetGrouped)
}
}
And my last attempt with 'looks' like what i want to achieve but its placed inside the navigation bar and the height of the bar doesn't grow with it so the bottom row of the component couldn't be interacted with.
var body: some View {
NavigationView {
List {
DayView(
date: weekStore.currentDate,
showAddingWorkout: $showAddingWorkout,
isEditing: isEditing
)
}
.listStyle(.insetGrouped)
.toolbar {
ToolbarItemGroup(placement: .principal) {
WeekHeader()
.environmentObject(weekStore)
}
}
}
}
This is the component i want to add above the list
And i want my list to look like a default list.
Please let me know if i need to elaborate my question or provide more code.

Weird behavior with NavigationSplitView and #State

I have a NavigationSplitView in my app, I have an #State variable in my detail view that gets created in init.
When I select something from the sidebar and the detail view renders, at first everything looks ok. But when I select a different item on the sidebar, the contents of the #state variable don't get recreated.
Using the debugger I can see the init of the detail view get called every time I select a new item in the sidebar, and I can see the #State variable get created. But when it actually renders, the #State variable still contains the previous selection's values.
I've reduced this problem to a test case I'll paste below. The top text in the detail view is a variable passed in from the sidebar, and the second line of text is generated by the #State variable. Expected behavior would be, if I select "one" the detail view would display "one" and "The name is one". If I select "two" the detail view would display "two" and "The name is two".
Instead, if I select "one" first, it displays correctly. But when I select "two", it displays "two" and "The name is one".
Note that if I select "two" as the first thing I do after launching the app, it correctly displays "two" and "The name is two", but when I click on "one" next, it will display "one" and "the name is two". So the state variable is being set once, then never changing again,
Here's the sample code and screenshots:
import SwiftUI
struct Item: Hashable, Identifiable {
let id = UUID()
let name: String
}
struct ContentView: View {
#State private var selectedItem: Item.ID? = nil
private let items = [Item(name: "one"), Item(name: "two"), Item(name: "three")]
func itemForID(_ id: UUID?) -> Item? {
guard let itemID = id else { return nil }
return items.first(where: { item in
item.id == itemID
})
}
var body: some View {
NavigationSplitView{
List(selection: $selectedItem) {
ForEach(items) { item in
Text(item.name)
.tag(item.id)
}
}
} detail: {
if let name = itemForID(selectedItem)?.name {
DetailView(name: name)
} else {
Text("Select an item")
}
}
}
}
struct DetailView: View {
#State var detailItem: DetailItem
var name: String
init(name: String) {
self.name = name
_detailItem = State(wrappedValue: DetailItem(name: name))
}
var body: some View {
VStack {
Text(name)
Text(detailItem.computedText)
}
}
}
struct DetailItem {
let name: String
var computedText: String {
return "The name is \(name)"
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Question. What is the purpose of having detailItem as a #State? if you remove the #State, this test case works.
Will the way computedText change over time?
struct DetailView: View {
// #State var detailItem: DetailItem
var detailItem: DetailItem
var name: String
init(name: String) {
self.name = name
// _detailItem = State(wrappedValue: DetailItem(name: name))
detailItem = DetailItem(name: name)
}
var body: some View {
VStack {
Text(name)
Text(detailItem.computedText)
}
}
}
This has nothing to do with NavigationSplitView, but how you initialise #State property.
According to the Apple document on #State (https://developer.apple.com/documentation/swiftui/state):
Don’t initialise a state property of a view at the point in the view hierarchy where you instantiate the view, because this can conflict with the storage management that SwiftUI provides.
As well as the documentation of init(wrappedValue:) (https://developer.apple.com/documentation/swiftui/state/wrappedvalue):
Don’t call this initializer directly. Instead, declare a property with the State attribute, and provide an initial value:
#State private var isPlaying: Bool = false
From my understanding, if you force to initialise the state in the view init, it will persist through the lifetime of the view, and subsequence change of it won't take any effect on the view.
The recommended way in Apple documentation is to create the struct in the parent view and pass it to the child view, and if you need to change the struct in the child view, use #Binding to allow read and write access.
If you want to ignore the documentation and force it to work, you can give an id to your DetailView, forcing it to refresh the view when the item id has changed:
var body: some View {
NavigationSplitView{
List(selection: $selectedItem) {
ForEach(items) { item in
Text(item.name)
.tag(item.id)
}
}
} detail: {
if let name = itemForID(selectedItem)?.name {
DetailView(name: name).id(selectedItem)
} else {
Text("Select an item")
}
}
}
Your Item struct is bad, if the name is unique it should be:
struct Item: Identifiable {
var id: String { name }
let name: String
}
Otherwise:
struct Item: Identifiable {
let id = UUID()
let name: String
}

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)

Scroll up to see TextField when the keyboard appears in SwiftUI

In my use case, I have to put a TextField below the available items in a List and by using that TextField, we can add items to the List.
Initially, there're no list items (items array is empty)
Here's a minimal, reproducible example
import SwiftUI
struct ContentView: View {
#State var itemName = ""
#State var items = [String]()
var body: some View {
NavigationView {
List {
ForEach(self.items, id: \.self) {
Text($0)
}
VStack {
TextField("Item Name", text: $itemName)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: {
self.items.append(self.itemName)
self.itemName = ""
}) {
Text("Add Item")
}
}
}
.navigationBarTitle(Text("Title"))
}
}
}
We can add a new item to the list by typing something in the TextField and clicking "Add Item" Button , Every item that we add using TextField appears above the TextField in the List. So the TextField goes down in the List (Just like Apple’s Reminders app).
If the app has many items (more than 7 items), the keyboard covers the TextField when the keyboard appears and we can’t see the TextField.
Check this screenshot:
What I want to know is how to automatically scroll the List (move the view up) to see the TextField when keyboard appears (like in Apple's Reminders app).
I had a similar problem in my recent project, the easiest way for me to solve it was to wrap UITextField in SwiftUI and from my custom wrapper reach to the parent scroll view and tell it to scroll when the keyboard appears. I tried my approach on your project and it seems to work.
If you take my code for the wrapper and other files from this GitHub folder: https://github.com/LostMoa/SwiftUI-Code-Examples/tree/master/ScrollTextFieldIntoVisibleRange and then replace the SwiftUI TextField with my custom view (TextFieldWithKeyboardObserver) then it should scroll.
import SwiftUI
struct ContentView: View {
#State var itemName = ""
#State var items = [String]()
var body: some View {
NavigationView {
List {
ForEach(self.items, id: \.self) {
Text($0)
}
VStack {
TextFieldWithKeyboardObserver(text: $itemName, placeholder: "Item Name")
Button(action: {
self.items.append(self.itemName)
self.itemName = ""
}) {
Text("Add Item")
}
}
}
.navigationBarTitle(Text("Title"))
}
}
}
I recently wrote an article explaining this solution: https://lostmoa.com/blog/ScrollTextFieldIntoVisibleRange/