SwiftUI Navigation with complex View Model (Defer creation until actually needed) - swiftui

this SwiftUI app uses a NavigationView with one dedicated view model for each of the two involved views.
What bothers me is the creation of the second view model (DetailsViewModel) for each of the shown links regardless of whether that link is ever going to be pressed or not. You can see this in console log.
This is a minimal example, imagine the data for the second view model coming from different entities, even different data stores and the func makeDetailsViewModel(id:) to be expensive in its execution.
I would like to defer the creation of the DetailsViewModel until the user has clicked one of the links so that only that one needs to be created instead of the potential dozens or or hundreds more others. It seams reasonable to me and straight forward in UIKit by injecting the view model in prepare(for segue:) but I don’t know how to achieve something like this using SwiftUI.
Apple’s tutorial Building Lists and Navigation and other tutorials I found use the same view model or none at all (e. g. Text("Second view")) for the second view.
How can this be done or is there a completely different way in SwiftUI to go about this? I may be mentally stuck in UIKit.
Thank you.
import SwiftUI
var people: [Person] = [
Person(id: 1, name: "Alice", age: 13, country: "USA"),
Person(id: 2, name: "Brad", age: 29, country: "Canada"),
Person(id: 3, name: "Chad", age: 29, country: "Canada"),
Person(id: 4, name: "Dorothy", age: 62, country: "Ireland"),
Person(id: 5, name: "Elizabeth", age: 40, country: "Sweden"),
Person(id: 6, name: "Francois", age: 21, country: "France"),
Person(id: 7, name: "Gary", age: 36, country: "Singapore"),
Person(id: 8, name: "Hans", age: 28, country: "Germany"),
Person(id: 9, name: "Ivan", age: 70, country: "Russia"),
Person(id: 10, name: "Jaime", age: 45, country: "Spain"),
]
var nameList: [ListEntryViewModel] = {
people.map { person in
return ListEntryViewModel(id: person.id, name: person.name)
}
}()
func makeDetailsViewModel(id: Int) -> DetailsViewModel {
print("Creating view model for DetailView")
let person = people.first(where: { $0.id == id})!
return DetailsViewModel(name: person.name, age: person.age, country: person.country)
}
// MARK: - Model
struct Person {
var id: Int
var name: String
var age: Int
var country: String
}
// MARK: - View Models
struct ListEntryViewModel: Identifiable {
var id: Int
var name: String
}
struct DetailsViewModel {
var name: String
var age: Int
var country: String
}
// MARK: - Views
struct DetailView: View {
var detailsViewModel: DetailsViewModel
var body: some View {
VStack {
Text(detailsViewModel.name)
Text("\(detailsViewModel.age) years")
Text("from \(detailsViewModel.country)")
}
}
}
struct ContentView: View {
var people: [ListEntryViewModel] = nameList
var body: some View {
NavigationView {
List(people) { person in
NavigationLink(destination: DetailView(detailsViewModel: makeDetailsViewModel(id: person.id))) {
Text(person.name)
}
}
.navigationTitle("People")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

I assume you wanted DeferView (from https://stackoverflow.com/a/61242931/12299030), like
List(people) { person in
NavigationLink(destination: DeferView {
DetailView(detailsViewModel: makeDetailsViewModel(id: person.id))
}) {
Text(person.name)
}
}

Related

SwiftUI: How to have a ForEach of editable TextFields?

I'm trying to have a component that basically starts with a single TextField for editing your home phone number, and then you can hit the add button to add different types of phone numbers to your account. for right now I just want to allow the user to edit the phone number, but in the future I'll probably make it so that there are actually two TextFields for each PhoneNumberListItem. 1 field for the editable name and 1 field for the editable phone itself. I'm coming from Android/Compose which is maybe where my line of thinking is stuck. appreciate any pointers.
func ListOfMyPhoneNumbers() -> some View {
#State var listOfMyPhones = [PhoneNumberListItem(name: "home", phone: "123")]
return VStack {
ForEach(listOfMyPhones) { i in
TextField(i.name, text: i.$phone).textFieldStyle(.roundedBorder)
}
Button("Add Phone") {
listOfMyPhones.append(PhoneNumberListItem(name: "other", phone: ""))
}.buttonStyle(.borderless)
Spacer()
}
.padding()
}
struct PhoneNumberListItem: Identifiable {
let id = UUID()
let name: String
#State var phone: String
}
Do not use #State var phone: String in your PhoneNumberListItem.
#State is only for use in a View. Just use a var.
Also use a struct for your view. With the following example code you will be able to edit the TextField in a ForEach, like this:
struct PhoneNumberListItem: Identifiable {
let id = UUID()
var name: String // <--- here
var phone: String // <--- here
}
struct ContentView: View {
var body: some View {
PhoneView() // <--- here
}
}
struct PhoneView: View { // <--- here
#State var listOfMyPhones = [PhoneNumberListItem(name: "home", phone: "123")]
var body: some View {
VStack {
// --- here
ForEach($listOfMyPhones) { $item in
TextField("phone", text: $item.phone)
TextField("name", text: $item.name)
}.textFieldStyle(.roundedBorder)
Button("Add Phone") {
listOfMyPhones.append(PhoneNumberListItem(name: "other", phone: ""))
}.buttonStyle(.borderless)
Spacer()
}
.padding()
}
}
As you progress with learning SwiftUI, you will want to use ObservableObject class to manage your data. Have a look at this link, it gives you some good examples of how to manage data in your app :
https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app

SwiftUI: Quiz, Toggle categories on/off and filter questions based on category status

I'm building a quiz app where the user should be able to select which categories they want to have, and then I'd like to filter the questions based on whether the category isActive.
I figure solving both would be too much for one post maybe, so I'm focusing on toggling the categories. I have tried creating a updateCategory function in my ViewModel but I can only tap on the first category. If I tap on any other category, only the first one gets updated (changes name and icon). Anyone that can point me in the right direction?
Here's my CategoryModel:
struct CategoryModel: Identifiable, Codable, Hashable {
var id: String
var icon: String
var name: String
var isActive: Bool
enum CodingKeys: String, CodingKey {
case id, icon, name, isActive
}
}
And here's my QuestionModel:
struct QuestionModel: Identifiable, Codable, Hashable {
var id = UUID().uuidString
var question: String
var category: String
var answer: Int
var options: [String]
enum CodingKeys: String, CodingKey {
case id, question, category, answer, options
}
// Match the answer index with the correct option
func theAnswer() -> String {
return (answer >= 0 && answer < options.count) ? options[answer] : ""
}
}
And here's a part of my GameModel:
struct Game {
// Get questions from JSON file
static var getQuestions: [QuestionModel] = Bundle.main.decode("questions.json")
// Shuffled questions
let questions = getQuestions.shuffled()
// Get categories
var categories: [CategoryModel] = [
CategoryModel(id: "", icon: "music.quarternote.3", name: "Musik", isActive: true),
CategoryModel(id: "", icon: "hourglass.bottomhalf.filled", name: "Historia", isActive: false),
CategoryModel(id: "", icon: "tv.fill", name: "Film & TV", isActive: true),
CategoryModel(id: "", icon: "pawprint.fill", name: "Natur & Vetenskap", isActive: true),
CategoryModel(id: "", icon: "globe.europe.africa.fill", name: "Geografi", isActive: true),
CategoryModel(id: "", icon: "sportscourt.fill", name: "Sport", isActive: true)
]
}
Here's part of my ViewModel:
class GameVM: ObservableObject {
#Published var game = Game()
//MARK: - Category logic
var categories: [CategoryModel] {
game.categories
}
func updateCategory(category: CategoryModel) {
if let index = categories.firstIndex(where: { $0.id == category.id }) {
game.categories[index] = category.updateCompletion()
}
}
var categoryIndices: Range<Int> {
game.categories.indices
}
}
Here's my CategoryListView where I display the categories:
struct CategoryListView: View {
#StateObject var viewModel = GameVM()
var columns: [GridItem] = Array(repeating: GridItem(.flexible(), spacing: 8), count: 2)
var body: some View {
VStack {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(viewModel.categories, id: \.self) { category in
CategoryCardView(icon: category.icon, name: category.name, isActive: category.isActive)
.frame(maxWidth: .infinity, minHeight: 120)
.background(
category.isActive == true ? Color(UIColor.systemGray6) : Color(UIColor.black)
)
.cornerRadius(24)
.onTapGesture {
withAnimation(.spring()) {
viewModel.updateCategory(category: category)
}
}
}
}
}
.padding()
.navigationTitle("Choose Categories")
.navigationBarTitleDisplayMode(.inline)
}
}
First part
You are initialising each category with the same id "",
var categories: [CategoryModel] = [
CategoryModel(id: "", icon: "music.quarternote.3", name: "Musik", isActive: true),
CategoryModel(id: "", icon: "hourglass.bottomhalf.filled", name: "Historia", isActive: false),
CategoryModel(id: "", icon: "tv.fill", name: "Film & TV", isActive: true),
CategoryModel(id: "", icon: "pawprint.fill", name: "Natur & Vetenskap", isActive: true),
CategoryModel(id: "", icon: "globe.europe.africa.fill", name: "Geografi", isActive: true),
CategoryModel(id: "", icon: "sportscourt.fill", name: "Sport", isActive: true)
]
causing this to return index 0 every time.
if let index = categories.firstIndex(where: { $0.id == category.id }) {
Best fix would be to initialise id in CategoryModel like you have in QuestionModel
struct CategoryModel: Identifiable, Codable, Hashable {
var id = UUID().uuidString
var icon: String
var name: String
var isActive: Bool
enum CodingKeys: String, CodingKey {
case id, icon, name, isActive
}
}
Second part
One way of achieving this is to create a filteredQuestions variable inside GameVM like this
var filteredQuestions: [QuestionModel] {
let filteredCategoryNames = categories
.filter(\.isActive) // filters for the active categories
.map(\.name) // turns it into an array containing the name of each category
return questions.filter { question in
filteredCategoryNames.contains(question.category)
}
}

iOS 15: Navigation link popping out, again

In the last few months, many developers have reported NavigationLinks to unexpectedly pop out and some workarounds have been published, including adding another empty link and adding .navigationViewStyle(StackNavigationViewStyle()) to the navigation view.
Here, I would like to demonstrate another situation under which a NavigationLink unexpectedly pops out:
When there are two levels of child views, i.e. parentView > childLevel1 > childLevel2, and childLevel2 modifies childLevel1, then, after going back from level 2 to level 1, level 1 pops out and parentView is shown.
I have filed a bug report but not heard from apple since. None of the known workarounds seem to work. Does someone have an idea what to make of this? Just wait for iOS 15.1?
Below is my code (iPhone app). In the parent view, there is a list of persons from which orders are taken. In childLevel1, all orders from a particular person are shown. Each order can be modified by clicking on it, which leads to childLevel2. In childLevel2, several options are available (here only one is shown for the sake of brevity), which is the reason why the user is supposed to leave childLevel2 via "< Back".
import SwiftUI
struct Person: Identifiable, Hashable {
let id: Int
let name: String
var orders: [Order]
}
struct Pastry: Identifiable, Hashable {
let id: Int
let name: String
}
struct Order: Hashable {
var paId: Int
var n: Int // used only in the real code
}
class Data : ObservableObject {
init() {
pastries = [
Pastry(id: 0, name: "Prezel"),
Pastry(id: 1, name: "Donut"),
Pastry(id: 2, name: "bagel"),
Pastry(id: 3, name: "cheese cake"),
]
persons = [
Person(id: 0, name: "Alice", orders: [Order(paId: 1, n: 1)]),
Person(id: 1, name: "Bob", orders: [Order(paId: 2, n: 1), Order(paId: 3, n: 1)])
]
activePersonsIds = [0, 1]
}
#Published var activePersonsIds: [Int] = []
#Published var persons: [Person] = []
#Published var pastries: [Pastry]
#Published var latestOrder = Order(paId: 0, n: 1)
lazy var pastryName: (Int) -> String = { (paId: Int) -> String in
if self.pastries.first(where: { $0.id == paId }) == nil {
return "undefined pastryId " + String(paId)
}
return self.pastries.first(where: { $0.id == paId })!.name
}
var activePersons : [Person] {
return activePersonsIds.compactMap {id in persons.first(where: {$0.id == id})}
}
}
#main
struct Bretzel_ProApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#Environment(\.colorScheme) var colorScheme
#StateObject var data = Data()
var body: some View {
TabView1(data: data)
// in the real code, there are more tabs
}
}
struct TabView1: View {
#StateObject var data: Data
var body: some View {
NavigationView {
List {
ForEach(data.activePersons, id: \.self) { person in
NavigationLink(
destination: EditPerson(data: data, psId: person.id),
label: {
VStack (alignment: .leading) {
Text(person.name)
}
}
)
}
}
.listStyle(PlainListStyle())
.navigationTitle("Orders")
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct EditPerson: View {
#ObservedObject var data: Data
var psId: Int
var body: some View {
let pindex: Int = data.persons.firstIndex(where: { $0.id == psId })!
let p: Person = data.persons[pindex]
List() {
ForEach (0...p.orders.count-1, id: \.self) { loop in
Section(header:
HStack() {
Text("BESTELLUNG " + String(loop+1))
}
) {
EPSubview1(data: data, psId: psId, loop: loop)
}
}
}.navigationTitle(p.name)
.listStyle(InsetGroupedListStyle())
}
}
struct EPSubview1: View {
#ObservedObject var data: Data
var psId: Int
var loop: Int
var body: some View {
let pindex: Int = data.persons.firstIndex(where: { $0.id == psId })!
let p: Person = data.persons[pindex]
let o1: Order = p.orders[loop]
NavigationLink(
destination: SelectPastry(data: data)
.onAppear() {
data.latestOrder.paId = o1.paId
}
.onDisappear() {
data.persons[pindex].orders[loop].paId = data.latestOrder.paId
},
label: {
VStack(alignment: .leading) {
Text(String(o1.n) + " x " + data.pastryName(o1.paId))
}
}
)
}
}
struct SelectPastry: View {
#ObservedObject var data : Data
var body: some View {
VStack {
List {
ForEach(data.pastries, id: \.self) {pastry in
Button(action: {
data.latestOrder.paId = pastry.id
}) {
Text(pastry.name)
.foregroundColor(data.latestOrder.paId == pastry.id ? .primary : .secondary)
}
}
}.listStyle(PlainListStyle())
}
}
}
The problem is your ForEach. Despite that fact that Person conforms to Identifiable, you're using \.self to identify the data. Because of that, every time an aspect of the Person changes, so does the value of self.
Instead, just use this form, which uses the id vended by Identifiable:
ForEach(data.activePersons) { person in
Which is equivalent to:
ForEach(data.activePersons, id: \.id) { person in

How to create SwiftUI List of NavigationLinks with dynamic view names

I am trying to create a simple list of Views for the user to visit, I cannot figure out how to replace view name with an array variable. In the example below destination: is hard coded as AVExample(), which is one of my views, but how do I use the names in the array?
struct test: View {
var views = ["AVExample", "ColorPickerExample", "DatePickerExample"]
var body: some View {
NavigationView {
List (views, id: \.self){ view in
NavigationLink(
destination: AVExample(),
label: {
Text("\(view)")
})
}
}
}
}
You can create a struct for the views and then use that array of structure. For Example:
struct ViewsList: Identifiable {
static func == (lhs: ViewsList, rhs: ViewsList) -> Bool {
return lhs.id == rhs.id
}
var id: Int
var name: String
var viewContent: AnyView
}
And then in your view class(test), create an array of ViewList structure:
var views = [ViewsList.init(id: 1, name: "Example", viewContent: AnyView(AVExample())), ViewsList.init(id: 2, name: "ColorPicker", viewContent: AnyView(ColorPicker()))]
Then you can loop over this array as below :
NavigationView {
List (views, id: \.id){ view in
NavigationLink(
destination: view.viewContent,
label: {
Text("\(view.name)")
})
}
Many thanks for the replies. I came up with the following solution which seems to work well.
private struct aView {
var id = UUID()
var view: AnyView
var label: String
}
private var views = [
aView(view: AnyView(AppStorageExample()), label: "App Storage"),
aView(view: AnyView(AppStoreRecommend()), label: "App Store Recommended"),
aView(view: AnyView(AVExample()), label: "AV Player"),
aView(view: AnyView(ColorPickerExample()), label: "Color Picker"),
]
var body: some View {
List (views, id: \.id) { view in
NavigationLink(
destination: view.view,
label: {
Text("\(view.label)")
})
}
}

SwiftUI - Passing Dynamic Data via Button

I'm trying to pass dynamic details from a master page to a detail page via a Button action. The view I'm passing to, I'm animating from off-screen, into the view using animation. I was able to get this to work when I was passing simple hard coded data, but I'm having an issue trying to figure out how to get it to pass data specific to the item row that was tapped.
I've wrapped the item row element with the button, but not sure I'm passing the right info into the button action parameter.
import SwiftUI
struct SearchView: View {
#State var show = false
var course = courseData
var body: some View {
ZStack {
ScrollView (.vertical, showsIndicators: false) {
VStack(spacing: 0.0) {
ForEach(course) { item in
Button(action: { self.show.toggle() }) {
CourseResultTile(perks: item.perks, hotdeals: item.hotdeals, image: item.image, courseName: item.courseName, distance: item.distance, reviews: item.reviews, priceSpan: item.priceSpan, timeSpan: item.timeSpan)
}
}
}
}.offset(y: 120)
// View I'm animating in below //
FacilityView(show: $show, perks: self.perks, image: self.image, courseName: self.courseName, address: self.address)
.rotation3DEffect(Angle(degrees: show ? 0 : 60), axis: (x: 0, y: 10.0, z: 0))
.animation(.default)
.offset(x: show ? 0 : -UIScreen.main.bounds.width)
.onTapGesture {
self.show.toggle()
}
}
.navigationBarTitle(Text("Search"), displayMode: .inline)
.navigationBarHidden(true)
.edgesIgnoringSafeArea(.top)
}
}
// Course Data Model //
struct Course : Identifiable {
var id = UUID()
var hotdeals: String
var perks: String
var image: String
var courseName: String
var distance: String
var reviews: String
var priceSpan: String
var timeSpan: String
var address: String
}
let courseData = [
Course(hotdeals: "Badge_HotDeals", perks: "Badge_NoPerks", image: "Image_CourseBackground", courseName: "Timacuan Golf Club", distance: "16.8 miles", reviews: "(562)", priceSpan: "$12.00 - $34.00", timeSpan: "1:53PM - 6:00 PM", address: "550 Timacuan Boulevard, Lake Mary, Florida, 32746"),
Course(hotdeals: "Badge_HotDeals", perks: "Badge_NoPerks", image: "Image_Course1", courseName: "Sanctuary Ridge Golf Club", distance: "20.1 miles", reviews: "(895)", priceSpan: "$12.00 - $34.95", timeSpan: "8:46 AM - 5:02 PM", address: "2601 Diamond Club Drive, Clermont, Florida, 34711"),
Course(hotdeals: "Badge_HotDeals", perks: "Badge_NoPerks", image: "Image_Course2", courseName: "Eagle Creek Golf Club - FL", distance: "14.3 miles", reviews: "(1K)", priceSpan: "$12.00 - $42.00", timeSpan: "8:42 AM - 5:51 PM", address: "10350 Emerson Lake Blvd, Orlando, Florida, 32832")
// FacilityView excerpt //
import SwiftUI
struct FacilityView: View {
var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}
var perks = "Badge_NoPerks"
var image = "Image_Course6"
var courseName = "Panther Lake: Orange County National"
var address = "16301 Phil Ritson Way, Winter Garden, FL 34787"
var holes = "18"
var par = "72"
var length = "6836"
var slope = "127"
#Binding var show: Bool
#State private var todaysDate = Date()
The error I'm getting is "Value of type 'SearchView' has no member 'variable I'm trying to pass'