Managing SwiftUI state in a typical list -> details app - swift

I'm building an app that's similar in structure to the Apple tutorial. My app has a ListView, which navigates to a DetailsView. The DetailsView is composed of a UIKit custom UIView, which I wrap with a UIViewRepresentable. So far, so good.
Now I have for now a list (let's say, of addresses) that I instantiate in memory, to be replaced with core data eventually. I'm able to bind (using #EnvironmentObject) the List<Address> to the ListView.
Where I'm stuck is binding the elements for each DetailsView. The Apple tutorial, referenced above, does something which I think isn't great - for some reason (that I can't figure out), it:
Binds the List to the details view (using #EnvironmentObject)
Passes the element (in the Apple tutorial case, landmark, in my case, an address) to the details view
During updating in response to a user gesture, it effectively searches the List for the element, to update the element in the list. This seems expensive especially if the list is large.
Here's the code for #3 which to me is suspect:
Button(action: {
self.userData.landmarks[self.landmarkIndex].isFavorite.toggle()
})
In their code, self.landmarkIndex does a linear search:
var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}
What I'm trying to do is to bind the element directly to the DetailsView and have updates to the element update the list. So far, I have been unable to achieve this.
Does anyone know the right way? It seems like the direction the tutorial is pointing to does not scale.

Instead of passing a Landmark object, you can pass a Binding<Landmark>.
LandmarkList.swift: Change the iteration from userData.landmark to their indices so you can get the binding. Then pass the bidding into LandmarkDetail and LandmarkRow
struct LandmarkList: View {
#EnvironmentObject private var userData: UserData
var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}
ForEach(userData.landmarks.indices) { index in
if !self.userData.showFavoritesOnly || self.userData.landmarks[index].isFavorite {
NavigationLink(
destination: LandmarkDetail(landmark: self.$userData.landmarks[index])
.environmentObject(self.userData)
) {
LandmarkRow(landmark: self.$userData.landmarks[index])
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
LandmarkDetail.swift: Change landmark into Binding<Landmark> and toggle the favorite based on the binding
#Binding var landmark: Landmark
.
.
.
Button(action: {
self.landmark.isFavorite.toggle()
}) {
if self.landmark
.isFavorite {
Image(systemName: "star.fill")
.foregroundColor(Color.yellow)
} else {
Image(systemName: "star")
.foregroundColor(Color.gray)
}
}
LandmarkRow.swift: Change landmark to a Binding
#Binding var landmark: Landmark

Here is an example of approach to use binding directly to model Address item
Assuming there is view model like, where Address is Identifiable struct
class AddressViewModel: ObservableObject {
#Published var addresses: [Address] = []
}
So somewhere in ListView u can use the following
ForEach (Array(vm.addresses.enumerated()), id: \.element.id) { (i, address) in
NavigationLink("\(address.title)",
destination: DetailsView(address: self.$vm.addresses[i])) // pass binding !!
}

Related

SwiftUI ForEach index jump by 2

I am working on SwiftUI ForEach. Below image shows what I want to achieve. For this purpose I need next two elements of array in single iteration, so that I can show two card in single go. I search on a lot but did find any way to jump index swiftUI ForEach.
Need to show two cards in single iteration
Here is my code in which I have added the element same array for both card which needs to be in sequence.
struct ContentView: View {
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
// I need jump of 2 indexes
ForEach(videos) { video in
//need to show the next two elements of the videos array
HStack {
videoCardView(video: video)
Spacer()
//video + 1
videoCardView(video: video)
}
.padding([.leading, .trailing], 30)
.padding([.top, .bottom], 10)
}
}
}
.background(Color(ColorName.appBlack.rawValue))
}
}
Any better suggestion how to build this view.
While LazyVGrid is probably the best solution for what you want to accomplish, it doesn't actually answer your question.
To "jump" an index is usually referred to as "stepping" in many programming languages, and in Swift it's called "striding".
You can stride (jump) an array by 2 like this:
ForEach(Array(stride(from: 0, to: array.count, by: 2)), id: \.self) { index in
// ...
}
You can learn more by taking a look at the Strideable protocol.
ForEach isn’t a for loop, many make that mistake. You need to supply identifiable data to it which should give you the clue to get your data into a suitable format first. You could process the array into another array containing a struct that has an id and holds the first and second video and pass that to the View that does the ForEach. View structs are lightweight make as many as you need.
You could also make a computed var but that wouldn’t be as efficient as a separate View because you might unnecessary recompute if something different changes.
Foreach is constrained compared to a 'for' loop. One way to fool ForEach into behaving differently is to create a shadow array for ForEach to loop through.
My purpose was slightly different than yours, but the workaround below seems like it could solve your challenge as well.
import SwiftUI
let images = ["house", "gear", "car"]
struct ContentView: View {
var body: some View {
VStack {
let looper = createCounterArray()
ForEach (looper, id:\.self) { no in
Image(systemName: images[no])
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Hello, world!")
}
.padding()
}
}
}
//
// return an array of the simulated loop data.
//
func createCounterArray() -> [Int] {
// create the data needed
return Array(arrayLiteral: 0,1,1,2)
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Multiple windows of the same SwiftUI (mac) app share the same state

so this is basically a Hail Mary, but I'm really out of ideas as to what could be causing this:
I have a small mac-app that uses the default WindowGroup, which according to the documentation ensures that.
"Each window created by the group maintains an independent state. For example, for each new window created from the group, new memory is allocated for any State or StateObject variables instantiated by the scene's view hierarchy."
Nevertheless, the NavigationView shows the same selected list across all windows. Put differently, selectedLabel shares and updates across multiple windows, even tho in my humble understanding this is not supposed to happen.
Another problem, which I don't know if it's related, is that both windowStyle and windowToolbarStyle set on this WindowGroup are ignored.
It may be a minor issue, but I'm really stuck here, so any help would be appreciated!
My MainApp (simplified):
import SwiftUI
#main
struct MainApp: App {
#State private var selectedLabel: ViewModel? = .init()
var body: some Scene {
WindowGroup {
SidebarView(selectedLabel: $selectedLabel)
}
.windowStyle(HiddenTitleBarWindowStyle())
.windowToolbarStyle(UnifiedCompactWindowToolbarStyle())
}
}
My Sidebar (also simplified):
import SFSafeSymbols
import SwiftUI
struct SidebarView: View {
#ObservedObject var viewModel = SidebarViewModel()
#Binding var selectedLabel: ViewModel?
var body: some View {
NavigationView {
VStack {
Button(action: {
viewModel.createStockList()
}, label: {
Image(systemSymbol: .plus)
})
List(viewModel.stockLists, id: \.id) { stockList in
NavigationLink(destination: StockListView(viewModel: stockList),
tag: stockList,
selection: $selectedLabel) {
Text(stockList.name)
}
}
}
}
}
}
You're storing your selectedLabel at the WindowGroup level and passing it to each sidebar. You should store that state in the SidebarView if you want it to be different.

Swift access binding from another struct

I'm new to swift so sorry if this question is confusing, I'm not 100% sure I'm even asking the right question. I'm loosely following this guide for creating a macOS app with objects in a sidebar. Unlike in that tutorial I'm using Coredata to store the objects, and I also started on macOS as opposed to expanding the app to work on macOS, but I don't think that should matter.
I'm on section 6, trying to create a binding for the selected sidebar element so I can use it for menu commands. However since I'm using Coredata instead of the data model that they're using, I'm not sure how to go about it. I got a binding to work storing the index of the selected item, as opposed to the actual object, like in the guide. However I would prefer to do it how it's done in the guide (the reason I did it the other way is I want to set a default selection in the sidebar, and I couldn't figure out how to do it this way), and I can't figure out how to get it to work with menu commands.
Here is my SwiftUI code that gets the data from Coredata and displays it, and includes the binding (The way I have it implemented using the index):
struct InstanceList: View {
#State var selectedInstanceIndex: Int? = 0
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
entity: Instance.entity(),
sortDescriptors:
[
NSSortDescriptor(
keyPath: \Instance.userOrder,
ascending: true),
NSSortDescriptor(
keyPath:\Instance.name,
ascending: true )
]
) private var instances: FetchedResults<Instance>
var body: some View {
NavigationView {
List {
ForEach(Array(zip(instances.indices, instances)), id: \.0) { index, instance in
NavigationLink(destination: InstanceView(instance: instance), tag: index, selection: $selectedInstanceIndex) {
InstanceRow(instance: instance)
}
}
.onMove(perform: move)
}
.listStyle(SidebarListStyle())
.navigationTitle("Instances")
.frame(minWidth: 150)
.toolbar {
ToolbarItem {
Button(action: toggleSidebar, label: {
Image(systemName: "sidebar.left")
})
}
}
}
}
//Some other unrelated code is down here
}
So how would I change the binding to store an Instance object the way it stores a Landmark in the tutorial, be able to set a default selection in the sidebar, and then access the binding in another struct so I can set up menu commands?
Hopefully this made sense...
Thanks!

How can I make my View stop unnecessary rendering with using CustomType for Binding in SwiftUI?

I have a CustomType called AppData, and it look like this:
struct AppData {
var stringOfText: String
var colorOfText: Color
}
I am using this AppData in my Views as State or Binding, I have 2 Views in my project called: ContentView and another one called BindingView. In my BindingView I am just using Color information of AppData. And I am expecting that my BindingView render or response for Color information changes! How ever in the fact BindingView render itself even for stringOfText Which is totally unnecessary, because that data is not used in View. I thought that maybe BindingView not just considering for colorOfText but also for all package that cary this data and that is appData So I decided help BindingView to understand when it should render itself, and I made that View Equatable, But that does not helped even. Still BindingView refresh and render itself on changes of stringOfText which it is wasting of rendering. How can I solve this issue of unnecessary rendering while using CustomType as type for my State or Binding.
struct ContentView: View {
#State private var appData: AppData = AppData(stringOfText: "Hello, world!", colorOfText: Color.purple)
var body: some View {
print("rendering ContentView")
return VStack(spacing: 20) {
Spacer()
EquatableView(content: BindingView(appData: $appData))
//BindingView(appData: $appData).equatable()
Spacer()
Button("update stringOfText from ContentView") { appData.stringOfText += " updated"}
Button("update colorOfText from ContentView") { appData.colorOfText = Color.red }
Spacer()
}
}
}
struct BindingView: View, Equatable {
#Binding var appData: AppData
var body: some View {
print("rendering BindingView")
return Text("123")
.bold()
.foregroundColor(appData.colorOfText)
}
static func == (lhs: BindingView, rhs: BindingView) -> Bool {
print("Equatable function used!")
return lhs.appData.colorOfText == rhs.appData.colorOfText
}
}
When using Equatable (and .equatable(), EquatableView()) on Views, SwiftUI makes some decisions about when to apply our own == functions and when it is going to compare the parameters on its own. See another one of my answers with more details about this here: https://stackoverflow.com/a/66617961/560942
In this case, it appears that even if Equatable is declared, SwiftUI skips it because it must be deciding that the POD (plain old data) in the Binding is determined to be non-equal and therefore it's going to refresh the view (again, even though one would think that the == would be enough to force it not to).
In the example you gave, obviously it's trivial for the system to re-render the Text element, so it doesn't really matter if this re-render happened. But, in the even that there actually are consequences to re-rendering, you could encapsulate the non-changing parts into a separate child view:
struct BindingView: View {
#Binding var appData: AppData
var body: some View {
print("rendering BindingView")
return BindingChildView(color: appData.colorOfText)
}
//no point in declaring == since it won't get called (at least with the current parameters
}
struct BindingChildView : View {
var color: Color
var body: some View {
print("rendering BindingChildView")
return Text("123")
.bold()
.foregroundColor(color)
}
}
In the above code, although the BindingView is re-rendered each time (although at basically zero cost, because nothing will change), the new child view is skipped because its parameters are equatable (even without declaring Equatable). So, in a non-contrived example, if the child view were expensive to render, this would solve the issue.

Using ForEach with a an array of Bindings (SwiftUI)

My objective is to dynamically generate a form from JSON. I've got everything put together except for generating the FormField views (TextField based) with bindings to a dynamically generated list of view models.
If I swap out the FormField views for just normal Text views it works fine (see screenshot):
ForEach(viewModel.viewModels) { vm in
Text(vm.placeholder)
}
for
ForEach(viewModel.viewModels) { vm in
FormField(viewModel: $vm)
}
I've tried to make the viewModels property of ConfigurableFormViewModel an #State var, but it loses its codability. JSON > Binding<[FormFieldViewModel] naturally doesn't really work.
Here's the gist of my code:
The first thing that you can try is this:
ForEach(0 ..< numberOfItems) { index in
HStack {
TextField("PlaceHolder", text: Binding(
get: { return items[index] },
set: { (newValue) in return self.items[index] = newValue}
))
}
}
The problem with the previous approach is that if numberOfItems is some how dynamic and could change because of an action of a Button for example, it is not going to work and it is going to throw the following error: ForEach<Range<Int>, Int, HStack<TextField<Text>>> count (3) != its initial count (0). 'ForEach(_:content:)' should only be used for *constant* data. Instead conform data to 'Identifiable' or use 'ForEach(_:id:content:)' and provide an explicit 'id'!
If you have that use case, you can do something like this, it will work even if the items are increasing or decreasing during the lifecycle of the SwiftView:
ForEach(items.indices, id:\.self ){ index in
HStack {
TextField("PlaceHolder", text: Binding(
get: { return items[index] },
set: { (newValue) in return self.items[index] = newValue}
))
}
}
Trying a different approach. The FormField maintains it's own internal state and publishes (via completion) when its text is committed:
struct FormField : View {
#State private var output: String = ""
let viewModel: FormFieldViewModel
var didUpdateText: (String) -> ()
var body: some View {
VStack {
TextField($output, placeholder: Text(viewModel.placeholder), onCommit: {
self.didUpdateText(self.output)
})
Line(color: Color.lightGray)
}.padding()
}
}
ForEach(viewModel.viewModels) { vm in
FormField(viewModel: vm) { (output) in
vm.output = output
}
}
Swift 5.5
From Swift 5.5 version, you can use binding array directly by passing in the bindable like this.
ForEach($viewModel.viewModels, id: \.self) { $vm in
FormField(viewModel: $vm)
}
A solution could be the following:
ForEach(viewModel.viewModels.indices, id: \.self) { idx in
FormField(viewModel: self.$viewModel.viewModels[idx])
}
Took some time to figure out a solution to this puzzle. IMHO, it's a major omission, especially with SwiftUI Apps proposing documents that has models in struct and using Binding to detect changes.
It's not cute, and it takes a lot of CPU time, so I would not use this for large arrays, but this actually has the intended result, and, unless someone points out an error, it follows the intent of the ForEach limitation, which is to only reuse if the Identifiable element is identical.
ForEach(viewModel.viewModels) { vm in
ViewBuilder.buildBlock(viewModel.viewModels.firstIndex(of: zone) == nil
? ViewBuilder.buildEither(first: Spacer())
: ViewBuilder.buildEither(second: FormField(viewModel: $viewModel.viewModels[viewModel.viewModels.firstIndex(of: vm)!])))
}
For reference, the ViewBuilder.buildBlock idiom can be done in the root of the body element, but if you prefer, you can put this with an if.