SwiftUI three column navigation layout not closing when selecting same item - ios

I'm using a three column navigation layout and facing the issue, that when selecting the same second column's item, the drawers won't close. If I take the files app as reference, selecting the same item again will close the drawer. Can someone tell me what's the issue? And is drawer the correct term?
Thanks in advance, Carsten
Code to reproduce:
import SwiftUI
extension UISplitViewController {
open override func viewDidLoad() {
preferredDisplayMode = .twoBesideSecondary
}
}
#main
struct TestApp: App {
#Environment(\.factory) var factory
var body: some Scene {
WindowGroup {
NavigationView {
ContentView(viewModel: factory.createVM1())
ContentView2(viewModel: factory.createVM2())
EmptyView()
}
}
}
}
struct FactoryKey: EnvironmentKey {
static let defaultValue: Factory = Factory()
}
extension EnvironmentValues {
var factory: Factory {
get {
return self[FactoryKey.self]
}
set {
self[FactoryKey.self] = newValue
}
}
}
class Factory {
func createVM1() -> ViewModel1 {
ViewModel1()
}
func createVM2() -> ViewModel2 {
ViewModel2()
}
func createVM3(from item: ViewModel2.Model) -> ViewModel3 {
ViewModel3(item: item)
}
}
class ViewModel1: ObservableObject {
struct Model: Identifiable {
let id: UUID = UUID()
let name: String
}
#Published var items: [Model]
init() {
items = (1 ... 4).map { Model(name: "First Column Item \($0)") }
}
}
struct ContentView: View {
#Environment(\.factory) var factory
#StateObject var viewModel: ViewModel1
var body: some View {
List {
ForEach(viewModel.items) { item in
NavigationLink(
destination: ContentView2(viewModel: factory.createVM2()),
label: {
Text(item.name)
})
}
}
}
}
class ViewModel2: ObservableObject {
struct Model: Identifiable {
let id: UUID = UUID()
let name: String
}
#Published var items: [Model]
init() {
items = (1 ... 4).map { Model(name: "Second Column Item \($0)") }
}
}
struct ContentView2: View {
#Environment(\.factory) var factory
#StateObject var viewModel: ViewModel2
var body: some View {
List {
ForEach(viewModel.items) { item in
NavigationLink(
destination: Detail(viewModel: factory.createVM3(from: item)),
label: {
Text(item.name)
})
}
}
}
}
class ViewModel3: ObservableObject {
let item: ViewModel2.Model
init(item: ViewModel2.Model) {
self.item = item
}
}
struct Detail: View {
#StateObject var viewModel: ViewModel3
var body: some View {
Text(viewModel.item.name)
}
}

Related

SwiftUI: How to know change in the published var of an ObservableObject

I have a View which shows list of Toggle's based on the Model data. This model has a #Published variable which changes based toggle selection.
class Model: ObservableObject, Hashable {
var id: String
#Published var isSelected: Bool
init(id: String, isSelected: Bool) {
self.id = id
self.isSelected = isSelected
}
...
}
class ViewModel: ObservableObject {
var list: [Model]
init() { ...}
...
func save() {
}
func clear() {
}
}
struct MyView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
ForEach(viewModel.list, id: \.self) { model in
Toggle(model.id, isOn: $model.isSelected)
}
Button("Done") {
viewModel.save()
}
Button("Clear") {
viewModel.clear()
}
...
}
}
}
The question is, since I have array of models with #Publsihed,
How to know if the user has changed any of the Toggles or not, so I can enable/disable Save button
How to know list all toggles that are changed(i.e. models isSelected is changed), say like when I press save
try something like this approach, to change the list of models isSelected property.
struct Model: Identifiable, Hashable {
var id: String
var isSelected: Bool
var hasChanged: Bool = false // <--- here
}
class ViewModel: ObservableObject {
// for testing
#Published var list: [Model] = [Model(id: "1", isSelected: false),
Model(id: "2", isSelected: false),
Model(id: "3", isSelected: true)]
func save() {
for mdl in list {
if mdl.hasChanged {
print("---> \(mdl.id) has changed") // <-- here
}
}
}
func clear() {
list = []
print("---> clear list: \(list)")
}
func reset() {
for i in list.indices {
list[i].isSelected = false
}
print("---> reset list: \(list)")
}
}
struct ContentView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
MyView(viewModel: viewModel)
}
}
struct MyView: View {
#ObservedObject var viewModel: ViewModel
#State var hasChanged = false
var body: some View {
VStack {
ForEach($viewModel.list) { $model in
Toggle(model.id, isOn: $model.isSelected)
.onChange(of: model.isSelected) { val in // <-- here
model.hasChanged.toggle()
}
}
Button("Save") {
viewModel.save()
}
Button("Clear") {
viewModel.clear()
}
Button("Reset") {
viewModel.reset()
}
}
}
}
EDIT-1:
If you want to count the number of times a particular Model has changed, then
simply add a counter to it, such as in this example code:
struct MyView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
ForEach($viewModel.list) { $model in
Toggle(model.id, isOn: $model.isSelected)
.onChange(of: model.isSelected) { val in
model.changeCount += 1 // <-- here
}
}
Button("Save") {
viewModel.save()
}
Button("Clear") {
viewModel.clear()
}
Button("Reset") {
viewModel.reset()
}
}
}
}
struct Model: Identifiable, Hashable {
var id: String
var isSelected: Bool
var changeCount: Int = 0 // <--- here
}
class ViewModel: ObservableObject {
// for testing
#Published var list: [Model] = [Model(id: "1", isSelected: false),
Model(id: "2", isSelected: false),
Model(id: "3", isSelected: true)]
func save() {
for var mdl in list {
print("---> \(mdl.id) has changed \(mdl.changeCount) times") // <-- here
}
}
func clear() {
list = []
print("---> clear list: \(list)")
}
func reset() {
for i in list.indices {
list[i].isSelected = false
}
print("---> reset list: \(list)")
}
}

Why is my .onAppear not getting triggered when an EnvironmentObject changes?

I'm trying to learn SwiftUI, but i can't seem to get my view to update. I want my WorkoutsView to refresh with the newly added workout when the user presses the "Add" button:
WorkoutTrackerApp:
#main
struct WorkoutTrackerApp: App {
var body: some Scene {
WindowGroup {
WorkoutTrackerView()
}
}
}
extension WorkoutTrackerApp {
struct WorkoutTrackerView: View {
#StateObject var workoutService = WorkoutService.instance
var body: some View {
NavigationView {
WorkoutsView { $workout in
NavigationLink(destination: WorkoutView(workout: $workout)){
Text(workout.title)
}
}
.toolbar {
Button("Add") {
workoutService.addNewWorkout()
}
}
.navigationTitle("Workouts")
}
.environmentObject(workoutService)
}
}
}
WorkoutsView:
import Foundation
import SwiftUI
struct WorkoutsView<Wrapper>: View where Wrapper: View {
#EnvironmentObject var workoutService: WorkoutService
#StateObject var viewModel: ViewModel
let workoutWrapper: (Binding<Workout>) -> Wrapper
init(_ viewModel: ViewModel = .init(), workoutWrapper: #escaping (Binding<Workout>) -> Wrapper) {
_viewModel = StateObject(wrappedValue: viewModel)
self.workoutWrapper = workoutWrapper
}
var body: some View {
List {
Section(header: Text("All Workouts")) {
ForEach($viewModel.workouts) { $workout in
workoutWrapper($workout)
}
}
}
.onAppear {
viewModel.workoutService = self.workoutService
viewModel.getWorkouts()
}
}
}
extension WorkoutsView {
class ViewModel: ObservableObject {
#Published var workouts = [Workout]()
var workoutService: WorkoutService?
func getWorkouts() {
workoutService?.getWorkouts { workouts in
self.workouts = workouts
}
}
}
}
WorkoutService:
import Foundation
class WorkoutService: ObservableObject {
static let instance = WorkoutService()
#Published var workouts = [Workout]()
private init() {
for i in 0...5 {
let workout = Workout(id: i, title: "Workout \(i)", exercises: [])
workouts.append(workout)
}
}
func getWorkouts(completion: #escaping ([Workout]) -> Void) {
DispatchQueue.main.async {
completion(self.workouts)
}
}
func addNewWorkout() {
let newWorkout = Workout(title: "New Workout")
workouts = workouts + [newWorkout]
}
}
The .onAppear in WorkoutsView only gets called once - when the view gets initialised for the first time. I want it to also get triggered when workoutService.addNewWorkout() gets called.
FYI: The WorkoutService is a 'mock' service, in the future i want to call an API there.
Figured it out, changed the body of WorkoutsView to this:
var body: some View {
List {
Section(header: Text("All Workouts")) {
ForEach($viewModel.workouts) { $workout in
workoutWrapper($workout)
}
}
}
.onAppear {
viewModel.workoutService = self.workoutService
viewModel.getWorkouts()
}
.onReceive(workoutService.objectWillChange) {
viewModel.getWorkouts()
}
}
Now the workouts list gets refreshed when workoutService publisher emits. The solution involved using the .onReceive to do something when the WorkoutService changes.

SwiftUI: List does not display the data

I tried to display the data of my server, the items appear and then disappeared a second after what?
Yet I'm displaying a static list that works ..
Look at the start of the video the bottom list:
My code:
struct HomeView: View {
#EnvironmentObservedResolve private var viewModel: HomeViewModel
var test = ["", ""]
var body: some View {
VStack {
Button(
action: {
},
label: {
Text("My Button")
}
)
List(test, id: \.self) { el in
Text("Work")
}
List(viewModel.items) { el in
Text("\(el.id)") // Not work
}
}
.padding()
.onAppear {
viewModel.getData()
}
}
}
My viewModel:
class HomeViewModel: ObservableObject {
private let myRepository: MyRepository
#Published var items: [Item] = []
init(myRepository: MyRepository) {
self.myRepository = myRepository
}
}
#MainActor extension HomeViewModel {
func getData() {
Task {
items = try await myRepository.getData()
}
}
}

SwiftUI MVVM Binding List Item

I am trying to create a list view and a detailed screen like this:
struct MyListView: View {
#StateObject var viewModel: MyListViewModel = MyListViewModel()
LazyVStack {
// https://www.swiftbysundell.com/articles/bindable-swiftui-list-elements/
ForEach(viewModel.items.identifiableIndicies) { index in
MyListItemView($viewModel.items[index])
}
}
}
class MyListViewModel: ObservableObject {
#Published var items: [Item] = []
...
}
struct MyListItemView: View {
#Binding var item: Item
var body: some View {
NavigationLink(destination: MyListItemDetailView(item: $item), label: {
...
})
}
}
struct MyListItemDetailView: View {
#Binding var item: Item
#StateObject var viewModel: MyListViewItemDetailModel
init(item: Binding<Item>) {
viewModel = MyListViewItemDetailModel(item: item)
}
var body: some View {
...
}
}
class MyListViewItemDetailModel: ObservableObject {
var item: Binding<Item>
...
}
I am not sure what's wrong with it, but I found that item variables are not synced with each other, even between MyListItemDetailView and MyListItemDetailViewModel.
Is there anyone who can provide the best practice and let me know what's wrong in my implmentation?
I think you should think about a minor restructure of your code, and use only 1
#StateObject/ObservableObject. Here is a cut down version of your code using
only one StateObject source of truth:
Note: AFAIK Binding is meant to be used in View struct not "ordinary" classes.
PS: what is identifiableIndicies?
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct Item: Identifiable {
let id = UUID().uuidString
var name: String = ""
}
struct MyListView: View {
#StateObject var viewModel: MyListViewModel = MyListViewModel()
var body: some View {
LazyVStack {
ForEach(viewModel.items.indices) { index in
MyListItemView(item: $viewModel.items[index])
}
}
}
}
class MyListViewModel: ObservableObject {
#Published var items: [Item] = [Item(name: "one"), Item(name: "two")]
}
struct MyListItemView: View {
#Binding var item: Item
var body: some View {
NavigationLink(destination: MyListItemDetailView(item: $item)){
Text(item.name)
}
}
}
class MyAPIModel {
func fetchItemData(completion: #escaping (Item) -> Void) {
// do your fetching here
completion(Item(name: "new data from api"))
}
}
struct MyListItemDetailView: View {
#Binding var item: Item
let myApiModel = MyAPIModel()
var body: some View {
VStack {
Button(action: fetchNewData) {
Text("Fetch new data")
}
TextField("edit item", text: $item.name).border(.red).padding()
}
}
func fetchNewData() {
myApiModel.fetchItemData() { itemData in
item = itemData
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
MyListView()
}.navigationViewStyle(.stack)
}
}
EDIT1:
to setup an API to call some functions, you could use something like this:
class MyAPI {
func fetchItemData(completion: #escaping (Item) -> Void) {
// do your stuff
}
}
and use it to obtain whatever data you require from the server.
EDIT2: added some code to demonstrate the use of an API.

How to run a method in ContentView when an ObservedObject changes in other class [Swift 5 iOS 13.4]

Here is my basic ContentView
struct ContentView: View
{
#ObservedObject var model = Model()
init(model: Model)
{
self.model = model
}
// How to observe model.networkInfo's value over here and run "runThis()" whenever the value changes?
func runThis()
{
// Function that I want to run
}
var body: some View
{
VStack
{
// Some widgets here
}
}
}
}
Here is my model
class Model: ObservableObject
{
#Published var networkInfo: String
{
didSet
{
// How to access ContentView and run "runThis" method from there?
}
}
}
I'm not sure if it is accessible ? Or if I can observe ObservableObject changes from View and run any methods?
Thanks in advance!
There are a number of ways to do this. If you want to runThis() when the
networkInfo changes then you could use something like this:
class Model: ObservableObject {
#Published var networkInfo: String = ""
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
VStack {
Button(action: {
self.model.networkInfo = "test"
}) {
Text("change networkInfo")
}
}.onReceive(model.$networkInfo) { _ in self.runThis() }
}
func runThis() {
print("-------> runThis")
}
}
another global way is this:
class Model: ObservableObject {
#Published var networkInfo: String = "" {
didSet {
NotificationCenter.default.post(name: NSNotification.Name("runThis"), object: nil)
}
}
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
VStack {
Button(action: {
self.model.networkInfo = "test"
}) {
Text("change networkInfo")
}
}.onReceive(
NotificationCenter.default.publisher(for: NSNotification.Name("runThis"))) { _ in
self.runThis()
}
}
func runThis() {
print("-------> runThis")
}
}

Resources