NavigationItem in nested NavigationStack is below NavigationBar - ios

I'm making an app where you create acronyms and their meaning with SwiftUI
The flow of the app consists of having a List of Acronym, when tapping on any element it takes you to the detailView for that particular acronym, and there you can tap on an edit button which should take you to the next screen to edit the available fields
However when creating the detailView, and adding my edit button as a NavigationItem it is being placed below the NavigationBar
AcronymsView
import SwiftUI
struct AcronymsView: View {
#State var isShowingDetailView = false
let acronyms = [
Acronym(short: "OMG", long: "Oh My God", userID: UUID()),
Acronym(short: "OMG 2", long: "Oh My God 2", userID: UUID()),
Acronym(short: "OMG 3", long: "Oh My God 3", userID: UUID())
]
let user = User(name: "User", username: "Username")
var body: some View {
NavigationStack {
List(acronyms) { acronym in
NavigationLink(value: acronym) {
AcronymsListCell(acronym: acronym)
}
}
.navigationDestination(for: Acronym.self) { acronym in
AcronymDetailView(acronym: acronym, user: user, categories: [])
}
.listStyle(.plain)
.toolbar {
Label("", systemImage: "plus")
}
.toolbarBackground(.visible, for: .navigationBar)
.navigationTitle("Acronyms")
.navigationBarTitleDisplayMode(.inline)
}
}
}
AcronymDetailView
import SwiftUI
struct AcronymDetailView: View {
#State var isEditing = false
#State var acronym: Acronym
#State var user: User
#State var categories: [Category]
var body: some View {
NavigationView {
Form {
Section("Acronym") {
TextField("Acronym", text: $acronym.short)
}
Section("Meaning") {
TextField("Meaning", text: $acronym.long)
}
Section("User") {
TextField("User", text: $user.name)
}
Section("Categories") {
VStack {
ForEach(categories, id: \.id) {
Text($0.name)
}
}
}
Section {
Button("Add to category") {
print("Wololo")
}
.buttonStyle(.plain)
}
}
.navigationTitle(acronym.short)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Text("Edit")
}
}
}
}
}
Acronym
import Foundation
final class Acronym: Codable, Identifiable {
var id: UUID?
var short: String
var long: String
var user: AcronymUser
init(short: String, long: String, userID: UUID) {
self.id = UUID()
self.short = short
self.long = long
let user = AcronymUser(id: userID)
self.user = user
}
}
extension Acronym: Hashable {
public func hash(into hasher: inout Hasher) {
return hasher.combine(id)
}
static func == (lhs: Acronym, rhs: Acronym) -> Bool {
lhs.id == rhs.id && lhs.short == rhs.short && lhs.long == rhs.long && lhs.user == rhs.user
}
}
final class AcronymUser: Codable, Identifiable {
var id: UUID
init(id: UUID) {
self.id = id
}
}
extension AcronymUser: Hashable {
public func hash(into hasher: inout Hasher) {
return hasher.combine(id)
}
static func == (lhs: AcronymUser, rhs: AcronymUser) -> Bool {
lhs.id == rhs.id
}
}
User
import Foundation
final class User: Codable {
var id: UUID?
var name: String
var username: String
init(name: String, username: String) {
self.name = name
self.username = username
}
}
Something funny (odd) that happens when I modify NavigationView to NavigationStack in AcronymDetailView is that it moves to the detailView when I tap on a List element, but then it goes back immediately and then I can see this error in the console:
A NavigationLink is presenting a value of type “Acronym” but there is no matching navigationDestination declaration visible from the location of the link. The link cannot be activated.
I'm not sure if it's related, but I thought it was worth mentioning it here just in case, and if you look closely to the gif, it looks like the button is in the right place

After a bit of trial and error and thinking of this logically, I don't really need another NavigationStack inside my AcronymDetailView
Also I replaced:
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Text("Edit")
}
}
For
.toolbar {
NavigationLink {
AcronymEditView()
} label: {
Text("Edit")
}
}
This way the code looks like this now:
import SwiftUI
struct AcronymDetailView: View {
#State var isEditing = false
#State var acronym: Acronym
#State var user: User
#State var categories: [Category]
var body: some View {
Form {
Section("Acronym") {
TextField("Acronym", text: $acronym.short)
}
Section("Meaning") {
TextField("Meaning", text: $acronym.long)
}
Section("User") {
TextField("User", text: $user.name)
}
Section("Categories") {
VStack {
ForEach(categories, id: \.id) {
Text($0.name)
}
}
}
Section {
Button("Add to category") {
print("Wololo")
}
.buttonStyle(.plain)
}
}
.navigationTitle(acronym.short)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
NavigationLink {
AcronymEditView()
} label: {
Text("Edit")
}
}
}
}
And now, everything works as it should

Related

Swiftui view doesn't refresh when navigated to from a different view

I have, what is probably, a beginner question here. I'm hoping there is something simple I'm missing or I have done wrong.
I essentially have a view which holds a struct containing an array of id strings. I then have a #FirestoreQuery which accesses a collection which holds objects with these id's. My view then displays a list with two sections. One for the id's in the original struct, and one for the remaining ones in the collection which don't appear in the array.
Each listitem is a separate view which displays the details of that item and also includes a button. When this button is pressed it adds/removes that object from the parent list and the view should update to show that object in the opposite section of the list from before.
My issue is that this works fine in the 'preview' in xcode when I look at this view on it's own. However if I run the app in the simulator, or even preview a parent view and navigate to this one, the refreshing of the view doesn't seem to work. I can press the buttons, and nothing happens. If i leave the view and come back, everything appears where it should.
I'll include all the files below. Is there something I'm missing here?
Thanks
Main view displaying the list with two sections
import SwiftUI
import FirebaseFirestoreSwift
struct SessionInvitesView: View {
#Environment(\.presentationMode) private var presentationMode
#FirestoreQuery(collectionPath: "clients") var clients : [Client]
#Binding var sessionViewModel : TrainingSessionViewModel
#State private var searchText: String = ""
#State var refresh : Bool = false
var enrolledClients : [Client] {
return clients.filter { sessionViewModel.session.invites.contains($0.id!) }
}
var availableClients : [Client] {
return clients.filter { !sessionViewModel.session.invites.contains($0.id!) }
}
var searchFilteredClients : [Client] {
if searchText.isEmpty {
return availableClients
} else {
return availableClients.filter {
$0.dogName.localizedCaseInsensitiveContains(searchText) ||
$0.name.localizedCaseInsensitiveContains(searchText) ||
$0.dogBreed.localizedCaseInsensitiveContains(searchText) }
}
}
var backButton: some View {
Button(action: { self.onCancel() }) {
Text("Back")
}
}
var body: some View {
NavigationView {
List {
Section(header: Text("Enrolled")) {
ForEach(enrolledClients) { client in
SessionInviteListItem(client: client, isEnrolled: true, onTap: removeClient)
}
}
Section(header: Text("Others")) {
ForEach(searchFilteredClients) { client in
SessionInviteListItem(client: client, isEnrolled: false, onTap: addClient)
}
}
}
.listStyle(.insetGrouped)
.searchable(text: $searchText)
.navigationTitle("Invites")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: backButton)
}
}
func removeClient(clientId: String) {
self.sessionViewModel.session.invites.removeAll(where: { $0 == clientId })
refresh.toggle()
}
func addClient(clientId: String) {
self.sessionViewModel.session.invites.append(clientId)
refresh.toggle()
}
func dismiss() {
self.presentationMode.wrappedValue.dismiss()
}
func onCancel() {
self.dismiss()
}
}
struct SessionInvitesView_Previews: PreviewProvider {
#State static var model = TrainingSessionViewModel()
static var previews: some View {
SessionInvitesView(sessionViewModel: $model)
}
}
List item view
import SwiftUI
struct SessionInviteListItem: View {
var client : Client
#State var isEnrolled : Bool
var onTap : (String) -> ()
var body: some View {
HStack {
VStack(alignment: .leading) {
HStack {
Text(client.dogName.uppercased())
.bold()
Text("(\(client.dogBreed))")
}
Text(client.name)
.font(.subheadline)
}
Spacer()
Button(action: { onTap(client.id!) }) {
Image(systemName: self.isEnrolled ? "xmark.circle.fill" : "plus.circle.fill")
}
.buttonStyle(.borderless)
.foregroundColor(self.isEnrolled ? .red : .green)
}
}
}
struct SessionInviteListItem_Previews: PreviewProvider {
static func doNothing(_ : String) {}
static var previews: some View {
SessionInviteListItem(client: buildSampleClient(), isEnrolled: false, onTap: doNothing)
}
}
Higher level view used to navigate to this list view
import SwiftUI
import FirebaseFirestoreSwift
struct TrainingSessionEditView: View {
// MARK: - Member Variables
#Environment(\.presentationMode) private var presentationMode
#FirestoreQuery(collectionPath: "clients") var clients : [Client]
#StateObject var sheetManager = SheetManager()
var mode: Mode = .new
var dateManager = DateManager()
#State var viewModel = TrainingSessionViewModel()
#State var sessionDate = Date.now
#State var startTime = Date.now
#State var endTime = Date.now.addingTimeInterval(3600)
var completionHandler: ((Result<Action, Error>) -> Void)?
// MARK: - Local Views
var cancelButton: some View {
Button(action: { self.onCancel() }) {
Text("Cancel")
}
}
var saveButton: some View {
Button(action: { self.onSave() }) {
Text("Save")
}
}
var addInviteButton : some View {
Button(action: { sheetManager.showInvitesSheet.toggle() }) {
HStack {
Text("Add")
Image(systemName: "plus")
}
}
}
// MARK: - Main View
var body: some View {
NavigationView {
List {
Section(header: Text("Details")) {
TextField("Session Name", text: $viewModel.session.title)
TextField("Location", text: $viewModel.session.location)
}
Section {
DatePicker(selection: $sessionDate, displayedComponents: .date) {
Text("Date")
}
.onChange(of: sessionDate, perform: { _ in
viewModel.session.date = dateManager.dateToStr(date: sessionDate)
})
DatePicker(selection: $startTime, displayedComponents: .hourAndMinute) {
Text("Start Time")
}
.onAppear() { UIDatePicker.appearance().minuteInterval = 15 }
.onChange(of: startTime, perform: { _ in
viewModel.session.startTime = dateManager.timeToStr(date: startTime)
})
DatePicker(selection: $endTime, displayedComponents: .hourAndMinute) {
Text("End Time")
}
.onAppear() { UIDatePicker.appearance().minuteInterval = 15 }
.onChange(of: endTime, perform: { _ in
viewModel.session.endTime = dateManager.timeToStr(date: endTime)
})
}
Section {
HStack {
Text("Clients")
Spacer()
Button(action: { self.sheetManager.showInvitesSheet.toggle() }) {
Text("Edit").foregroundColor(.blue)
}
}
ForEach(viewModel.session.invites, id: \.self) { clientID in
self.createClientListElement(id: clientID)
}
.onDelete(perform: deleteInvite)
}
Section(header: Text("Notes")) {
TextField("Add notes here...", text: $viewModel.session.notes)
}
if mode == .edit {
Section {
HStack {
Spacer()
Button("Delete Session") {
sheetManager.showActionSheet.toggle()
}
.foregroundColor(.red)
Spacer()
}
}
}
}
.navigationTitle(mode == .new ? "New Training Session" : "Edit Training Session")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(
leading: cancelButton,
trailing: saveButton)
.actionSheet(isPresented: $sheetManager.showActionSheet) {
ActionSheet(title: Text("Are you sure?"),
buttons: [
.destructive(Text("Delete Session"), action: { self.onDelete() }),
.cancel()
])
}
.sheet(isPresented: $sheetManager.showInvitesSheet) {
SessionInvitesView(sessionViewModel: $viewModel)
}
}
}
func createClientListElement(id: String) -> some View {
let client = clients.first(where: { $0.id == id })
if let client = client {
return AnyView(ClientListItem(client: client))
}
else {
return AnyView(Text("Invalid Client ID: \(id)"))
}
}
func deleteInvite(indexSet: IndexSet) {
viewModel.session.invites.remove(atOffsets: indexSet)
}
// MARK: - Local Event Handlers
func dismiss() {
self.presentationMode.wrappedValue.dismiss()
}
func onCancel() {
self.dismiss()
}
func onSave() {
self.viewModel.onDone()
self.dismiss()
}
func onDelete() {
self.viewModel.onDelete()
self.dismiss()
self.completionHandler?(.success(.delete))
}
// MARK: - Sheet Management
class SheetManager : ObservableObject {
#Published var showActionSheet = false
#Published var showInvitesSheet = false
}
}
struct TrainingSessionEditView_Previews: PreviewProvider {
static var previews: some View {
TrainingSessionEditView(viewModel: TrainingSessionViewModel(session: buildSampleTrainingSession()))
}
}
I'm happy to include any of the other files if you think it would help. Thanks in advance!

SwiftUI: Checkmarks disappear when changing from one view to another using NavigationLink

I'm trying to make an app that is displaying lists with selections/checkmarks based on clicked NavigationLink. The problem I encountered is that my selections disappear when I go back to main view and then I go again inside the NavigationLink. I'm trying to save toggles value in UserDefaults but it's not working as expected. Below I'm pasting detailed and main content view.
Second view:
struct CheckView: View {
#State var isChecked:Bool = false
#EnvironmentObject var numofitems: NumOfItems
var title:String
var count: Int=0
var body: some View {
HStack{
ScrollView {
Toggle("\(title)", isOn: $isChecked)
.toggleStyle(CheckToggleStyle())
.tint(.mint)
.onChange(of: isChecked) { value in
if isChecked {
numofitems.num += 1
print(value)
} else{
numofitems.num -= 1
}
UserDefaults.standard.set(self.isChecked, forKey: "locationToggle")
}.onTapGesture {
}
.onAppear {
self.isChecked = UserDefaults.standard.bool(forKey: "locationToggle")
}
Spacer()
}.frame(maxWidth: .infinity,alignment: .topLeading)
}
}
}
Main view:
struct CheckListView: View {
#State private var menu = Bundle.main.decode([ItemsSection].self, from: "items.json")
var body: some View {
NavigationView{
List{
ForEach(menu){
section in
NavigationLink(section.name) {
VStack{
ScrollView{
ForEach(section.items) { item in
CheckView( title: item.name)
}
}
}
}
}
}
}.navigationBarHidden(true)
.navigationViewStyle(StackNavigationViewStyle())
.listStyle(GroupedListStyle())
.navigationViewStyle(StackNavigationViewStyle())
}
}
ItemsSection:
[
{
"id": "9DC6D7CB-B8E6-4654-BAFE-E89ED7B0AF94",
"name": "Africa",
"items": [
{
"id": "59B88932-EBDD-4CFE-AE8B-D47358856B93",
"name": "Algeria"
},
{
"id": "E124AA01-B66F-42D0-B09C-B248624AD228",
"name": "Angola"
}
Model:
struct ItemsSection: Codable, Identifiable, Hashable {
var id: UUID = UUID()
var name: String
var items: [CountriesItem]
}
struct CountriesItem: Codable, Equatable, Identifiable,Hashable {
var id: UUID = UUID()
var name: String
}
As allready stated in the comment you have to relate the isChecked property to the CountryItem itself. To get this to work i have changed the model and added an isChecked property. You would need to add this to the JSON by hand if the JSON allread exists.
struct CheckView: View {
#EnvironmentObject var numofitems: NumOfItems
//use a binding here as we are going to manipulate the data coming from the parent
//and pass the complete item not only the name
#Binding var item: CountriesItem
var body: some View {
HStack{
ScrollView {
//use the name and the binding to the item itself
Toggle("\(item.name)", isOn: $item.isChecked)
.toggleStyle(.button)
.tint(.mint)
// you now need the observe the isChecked inside of the item
.onChange(of: item.isChecked) { value in
if value {
numofitems.num += 1
print(value)
} else{
numofitems.num -= 1
}
}.onTapGesture {
}
Spacer()
}.frame(maxWidth: .infinity,alignment: .topLeading)
}
}
}
struct CheckListView: View {
#State private var menu = Bundle.main.decode([ItemsSection].self, from: "items.json")
var body: some View {
NavigationView{
List{
ForEach($menu){ // from here on you have to pass a binding on to the decendent views
// mark the $ sign in front of the property name
$section in
NavigationLink(section.name) {
VStack{
ScrollView{
ForEach($section.items) { $item in
//Pass the complete item to the CheckView not only the name
CheckView(item: $item)
}
}
}
}
}
}
}.navigationBarHidden(true)
.navigationViewStyle(StackNavigationViewStyle())
.listStyle(GroupedListStyle())
.navigationViewStyle(StackNavigationViewStyle())
}
}
Example JSON:
[
{
"id": "9DC6D7CB-B8E6-4654-BAFE-E89ED7B0AF94",
"name": "Africa",
"items": [
{
"id": "59B88932-EBDD-4CFE-AE8B-D47358856B93",
"name": "Algeria",
"isChecked": false
},
{
"id": "E124AA01-B66F-42D0-B09C-B248624AD228",
"name": "Angola",
"isChecked": false
}
]
}
]
Remarks:
The aproach with JSON and storing this in the bundle will prevent you from persisting the isChecked property between App launches. Because you cannot write to the Bundle from within your App. The choice will persist as long as the App is active but will be back to default as soon as you either reinstall or force quit it.
As already mentioned in the comment, I don'r see where you read back from UserDefaults, so whatever gets stored there, you don't read it. But even if so, each Toggle is using the same key, so you are overwriting the value.
Instead of using the #State var isChecked, which is used just locally, I'd create another struct item which gets the title from the json and which contains a boolean that gets initialized with false.
From what I understood, I assume a solution could look like the following code. Just a few things:
I am not sure how your json looks like, so I am not loading from a json, I add ItemSections Objects with a title and a random number of items (actually just titles again) with a function.
Instead of a print with the number of checked toggles, I added a text output on the UI. It shows you on first page the number of all checked toggles.
Instead of using UserDefaults I used #AppStorage.
To make that work you have to make Array conform to RawRepresentable you achieve that with the following code/extension (just add it once somewhere in your project)
Maybe you should thing about a ViewModel (e.g. ItemSectionViewModel), to load the data from the json and provide it to the views as an #ObservableObject.
The code for the views:
//
// CheckItem.swift
// CheckItem
//
// Created by Sebastian on 24.08.22.
//
import SwiftUI
struct ContentView: View {
var body: some View {
VStack() {
CheckItemView()
}
}
}
struct CheckItemView: View {
let testStringForTestData: String = "Check Item Title"
#AppStorage("itemSections") var itemSections: [ItemSection] = []
func addCheckItem(title: String, numberOfItems: Int) {
var itemArray: [Item] = []
for i in 0...numberOfItems {
itemArray.append(Item(title: "item \(i)"))
}
itemSections.append(ItemSection(title: title, items: itemArray))
}
func getSelectedItemsCount() -> Int{
var i: Int = 0
for itemSection in itemSections {
let filteredItems = itemSection.items.filter { item in
return item.isOn
}
i = i + filteredItems.count
}
return i
}
var body: some View {
NavigationView{
VStack() {
List(){
ForEach(itemSections.indices, id: \.self){ id in
NavigationLink(destination: ItemSectionDetailedView(items: $itemSections[id].items)) {
Text(itemSections[id].title)
}
.padding()
}
}
Text("Number of checked items: \(self.getSelectedItemsCount())")
.padding()
Button(action: {
self.addCheckItem(title: testStringForTestData, numberOfItems: Int.random(in: 0..<4))
}) {
Text("Add Item")
}
.padding()
}
}
}
}
struct ItemSectionDetailedView: View {
#Binding var items: [Item]
var body: some View {
ScrollView() {
ForEach(items.indices, id: \.self){ id in
Toggle(items[id].title, isOn: $items[id].isOn)
.padding()
}
}
}
}
struct ItemSection: Identifiable, Hashable, Codable {
var id: String = UUID().uuidString
var title: String
var items: [Item]
}
struct Item: Identifiable, Hashable, Codable {
var id: String = UUID().uuidString
var title: String
var isOn: Bool = false
}
Here the adjustment to work with #AppStorage:
extension Array: RawRepresentable where Element: Codable {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode([Element].self, from: data)
else {
return nil
}
self = result
}
public var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8)
else {
return "[]"
}
return result
}
}

Filter #Published array in SwiftUI List removes elements in list

I am trying to implement a list functionality similar to to Handling User Input example, the interface shows a list the user can filter depending on boolean values. I want to add the following differences from the example:
Can edit list elements from the row itself
Move the filter logic to a ViewModel class
I've tried many approaches without success one of them:
ViewModel:
class TaskListViewModel : ObservableObject {
private var cancelables = Set<AnyCancellable>()
private var allTasks: [Task] =
[ Task(id: "1",name: "Task1", description: "Description", done: false),
Task(id: "2",name: "Task2", description: "Description", done: false)]
#Published var showNotDoneOnly = false
#Published var filterdTasks: [Task] = []
init() {
filterdTasks = allTasks
$showNotDoneOnly.map { notDoneOnly in
if notDoneOnly {
return self.filterdTasks.filter { task in
!task.done
}
}
return self.filterdTasks
}.assign(to: \.filterdTasks, on: self)
.store(in: &cancelables)
}
}
View:
struct TaskListView: View {
#ObservedObject private var taskListViewModel = TaskListViewModel()
var body: some View {
NavigationView {
VStack {
Toggle(isOn: $taskListViewModel.showNotDoneOnly) {
Text("Undone only")
}.padding()
List {
ForEach(taskListViewModel.filterdTasks.indices, id: \.self) { idx in
TaskRow(task: $taskListViewModel.filterdTasks[idx])
}
}
}.navigationBarTitle(Text("Tasks"))
}
}
}
TaskRow:
struct TaskRow: View {
#Binding var task: Task
var body: some View {
HStack {
Text(task.name)
Spacer()
Toggle("", isOn: $task.done )
}
}
}
With this approach the list is filtered when the user enable the filter but when it is disabled the list lose the previously filtered elements. If I change the code to restore the filter elements like this:
$showNotDoneOnly.map { notDoneOnly in
if notDoneOnly {
return self.filterdTasks.filter { task in
!task.done
}
}
return self.allTasks
}.assign(to: \.filterdTasks, on: self)
The list lose the edited elements.
I've also tried making allTask property to a #Published dictionary by without success. Any idea on how to implement this? Is ther any better approach to do this in SwiftUi?
Thanks
SwiftUI architecture is really just state and view. Here, it's the state of the Task that you are most interested in (done/undone). Make the Task an Observable class that publishes it's done/undone state change. Bind the UI toggle switch in TaskRow directly to that done/undone in the Task model (remove the intermediary list of indexes), then you don't need any logic to publish state changes manually.
The second state for the app is filtered/unfiltered for the list. That part it seems you already have down.
This is one possible way to do it.
EDIT: Here's a more full example on how to keep the data state and view separate. The Task model is the central idea here.
#main
struct TaskApp: App {
#StateObject var model = Model()
var body: some Scene {
WindowGroup {
TaskListView()
.environmentObject(model)
}
}
}
class Model: ObservableObject {
#Published var tasks: [Task] = [
Task(name: "Task1", description: "Description"),
Task(name: "Task2", description: "Description")
] // some initial sample data
func updateTasks() {
//
}
}
class Task: ObservableObject, Identifiable, Hashable {
var id: String { name }
let name, description: String
#Published var done: Bool = false
init(name: String, description: String) {
self.name = name
self.description = description
}
static func == (lhs: Task, rhs: Task) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
struct TaskListView: View {
#EnvironmentObject var model: Model
var filter: ([Task]) -> [Task] = { $0.filter { $0.done } }
#State private var applyFilter = false
var body: some View {
NavigationView {
VStack {
Toggle(isOn: $applyFilter) {
Text("Undone only")
}.padding()
List {
ForEach(
(applyFilter ? filter(model.tasks) : model.tasks), id: \.self) { task in
TaskRow(task: task)
}
}
}.navigationBarTitle(Text("Tasks"))
}
}
}
struct TaskRow: View {
#ObservedObject var task: Task
var body: some View {
HStack {
Text(task.name)
Spacer()
Toggle("", isOn: $task.done).labelsHidden()
}
}
}
Finally I've managed to implement the list functionality whith the conditions previously listed. Based on Cenk Bilgen answer:
ListView:
struct TaskListView: View {
#ObservedObject private var viewModel = TaskListViewModel()
var body: some View {
NavigationView {
VStack {
Toggle(isOn: $viewModel.filterDone) {
Text("Filter done")
}.padding()
List {
ForEach(viewModel.filter(), id: \.self) { task in
TaskRow(task: task)
}
}
}.navigationBarTitle(Text("Tasks"))
}.onAppear {
viewModel.fetchTasks()
}
}
}
TaskRow:
struct TaskRow: View {
#ObservedObject var task: TaskViewModel
var body: some View {
HStack {
Text(task.name)
Spacer()
Toggle("", isOn: $task.done )
}
}
}
TaskListViewModel
class TaskListViewModel : ObservableObject {
private var cancelables = Set<AnyCancellable>()
#Published var filterDone = false
#Published var tasks: [TaskViewModel] = []
func filter() -> [TaskViewModel] {
filterDone ? tasks.filter { !$0.done } : tasks
}
func fetchTasks() {
let id = 0
[
TaskViewModel(name: "Task \(id)", description: "Description"),
TaskViewModel(name: "Task \(id + 1)", description: "Description")
].forEach { add(task: $0) }
}
private func add(task: TaskViewModel) {
tasks.append(task)
task.objectWillChange
.sink { self.objectWillChange.send() }
.store(in: &cancelables)
}
}
Notice here each TaskViewModel will propagate objectWillChange event to TaskListViewModel to update the filter when a task is marked as completed.
TaskViewModel:
class TaskViewModel: ObservableObject, Identifiable, Hashable {
var id: String { name }
let name: String
let description: String
#Published var done: Bool = false
init(name: String, description: String, done: Bool = false) {
self.name = name
self.description = description
self.done = done
}
static func == (lhs: TaskViewModel, rhs: TaskViewModel) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
This is the main difference from the original approach: Changing the row model from a simple struct included as #Binding to an ObservableObject

SwiftUI: How to update passing array item in the other view

I'm trying to update arrays item with typed new value into Textfield, but List is not updated with edited value.
My Code is:
Model:
struct WalletItem: Identifiable{
let id = UUID()
var name:String
var cardNumber:String
var type:String
var cvc:String
let pin:String
var dateOfExpiry:String
}
ModelView:
class Wallet: ObservableObject{
#Published var wallets = [
WalletItem(name: "BSB", cardNumber: "123456789", type: "master card", cvc: "1234", pin: "1234", dateOfExpiry: "2016-06-29"),
WalletItem(name: "Alpha bank", cardNumber: "123456789", type: "master card", cvc: "1234", pin: "1234", dateOfExpiry: "2017-03-12"),
WalletItem(name: "MTБ", cardNumber: "123456789", type: "master card", cvc: "1234", pin: "1234", dateOfExpiry: "2020-11-12"),
]
}
First View:
struct WalletListView: View {
// Properties
// ==========
#ObservedObject var wallet = Wallet()
#State var isNewItemSheetIsVisible = false
var body: some View {
NavigationView {
List(wallet.wallets) { walletItem in
NavigationLink(destination: EditWalletItem(walletItem: walletItem)){
Text(walletItem.name)
}
}
.navigationBarTitle("Cards", displayMode: .inline)
.navigationBarItems(
leading: Button(action: { self.isNewItemSheetIsVisible = true
}) {
HStack {
Image(systemName: "plus.circle.fill")
Text("Add item")
}
}
)
}
.sheet(isPresented: $isNewItemSheetIsVisible) {
NewWalletItem(wallet: self.wallet)
}
}
}
and Secondary View:
struct EditWalletItem: View {
#State var walletItem: WalletItem
#Environment(\.presentationMode) var presentationMode
var body: some View {
Form{
Section(header: Text("Card Name")){
TextField("", text: $walletItem.name)
}
}
.navigationBarItems(leading:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
})
{
Text("Back")
}, trailing:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
})
{
Text("Save")
})
}
}
P.S: If I use #Binding instead of the #State I've got an error in the first view: Initializer init(_:) requires that Binding<String> conform to StringProtocol
Here are modified parts (tested & works with Xcode 11.2 / iOS 13.2):
Sure over binding
struct EditWalletItem: View {
#Binding var walletItem: WalletItem
Place to pass it
List(Array(wallet.wallets.enumerated()), id: .element.id) { (i, walletItem) in
NavigationLink(destination: EditWalletItem(walletItem: self.$wallet.wallets[i])){
Text(walletItem.name)
}
}
ForEach(Array(list.enumerated())) will only work correctly if the list is an Array but not for an ArraySlice, and it has the downside of copying the list.
A better approach is using a .indexed() helper:
struct IndexedCollection<Base: RandomAccessCollection>: RandomAccessCollection {
typealias Index = Base.Index
typealias Element = (index: Index, element: Base.Element)
let base: Base
var startIndex: Index { self.base.startIndex }
var endIndex: Index { self.base.endIndex }
func index(after i: Index) -> Index {
self.base.index(after: i)
}
func index(before i: Index) -> Index {
self.base.index(before: i)
}
func index(_ i: Index, offsetBy distance: Int) -> Index {
self.base.index(i, offsetBy: distance)
}
subscript(position: Index) -> Element {
(index: position, element: self.base[position])
}
}
extension RandomAccessCollection {
func indexed() -> IndexedCollection<Self> {
IndexedCollection(base: self)
}
}
Example:
// SwiftUIPlayground
// https://github.com/ralfebert/SwiftUIPlayground/
import Foundation
import SwiftUI
struct Position {
var id = UUID()
var count: Int
var name: String
}
class BookingModel: ObservableObject {
#Published var positions: [Position]
init(positions: [Position] = []) {
self.positions = positions
}
}
struct EditableListExample: View {
#ObservedObject var bookingModel = BookingModel(
positions: [
Position(count: 1, name: "Candy"),
Position(count: 0, name: "Bread"),
]
)
var body: some View {
// >>> Passing a binding into an Array via index:
List(bookingModel.positions.indexed(), id: \.element.id) { i, _ in
PositionRowView(position: self.$bookingModel.positions[i])
}
}
}
struct PositionRowView: View {
#Binding var position: Position
var body: some View {
Stepper(
value: $position.count,
label: {
Text("\(position.count)x \(position.name)")
}
)
}
}
struct EditableListExample_Previews: PreviewProvider {
static var previews: some View {
EditableListExample()
}
}
See also:
How does the Apple-suggested .indexed() property work in a ForEach?

List reload animation glitches

So I have a list that changes when user fill in search keyword, and when there is no result, all the cells collapse and somehow they would fly over to the first section which looks ugly. Is there an error in my code or is this an expected SwiftUI behavior? Thanks.
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel = ViewModel(photoLibraryService: PhotoLibraryService.shared)
var body: some View {
NavigationView {
List {
Section {
TextField("Enter Album Name", text: $viewModel.searchText)
}
Section {
if viewModel.libraryAlbums.count > 0 {
ForEach(viewModel.libraryAlbums) { libraryAlbum -> Text in
let title = libraryAlbum.assetCollection.localizedTitle ?? "Album"
return Text(title)
}
}
}
}.listStyle(GroupedListStyle())
.navigationBarTitle(
Text("Albums")
).navigationBarItems(trailing: Button("Add Album", action: {
PhotoLibraryService.shared.createAlbum(withTitle: "New Album \(Int.random(in: 1...100))")
}))
}.animation(.default)
}
}
1) you have to use some debouncing to reduce the needs to refresh the list, while typing in the search field
2) disable animation of rows
The second is the hardest part. the trick is to force recreate some View by setting its id.
Here is code of simple app (to be able to test this ideas)
import SwiftUI
import Combine
class Model: ObservableObject {
#Published var text: String = ""
#Published var debouncedText: String = ""
#Published var data = ["art", "audience", "association", "attitude", "ambition", "assistance", "awareness", "apartment", "artisan", "airport", "atmosphere", "actor", "army", "attention", "agreement", "application", "agency", "article", "affair", "apple", "argument", "analysis", "appearance", "assumption", "arrival", "assistant", "addition", "accident", "appointment", "advice", "ability", "alcohol", "anxiety", "ad", "activity"].map(DataRow.init)
var filtered: [DataRow] {
data.filter { (row) -> Bool in
row.txt.lowercased().hasPrefix(debouncedText.lowercased())
}
}
var id: UUID {
UUID()
}
private var store = Set<AnyCancellable>()
init(delay: Double) {
$text
.debounce(for: .seconds(delay), scheduler: RunLoop.main)
.sink { [weak self] (s) in
self?.debouncedText = s
}.store(in: &store)
}
}
struct DataRow: Identifiable {
let id = UUID()
let txt: String
init(_ txt: String) {
self.txt = txt
}
}
struct ContentView: View {
#ObservedObject var search = Model(delay: 0.5)
var body: some View {
NavigationView {
VStack(alignment: .leading) {
TextField("filter", text: $search.text)
.padding(.vertical)
.padding(.horizontal)
List(search.filtered) { (e) in
Text(e.txt)
}.id(search.id)
}.navigationBarTitle("Navigation")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
and i am happy with the result

Resources