Require a SwitftUI View in a protocol without boilerplate - swift

[ Ed: Once I had worked this out, I edited the title of this question to better reflect what I actually needed. - it wasn't until I answered my own question that I clarified what I needed :-) ]
I am developing an App using SwiftUI on IOS in which I have 6 situations where I will have a List of items which I can select and in all cases the action will be to move to a screen showing that Item.
I am a keen "DRY" advocate so rather than write the List Code 6 times I want to abstract away the list and select code and for each of the 6 scenarios I want to just provide what is unique to that instance.
I want to use a protocol but want to keep boilerplate to a minimum.
My protocol and associated support is this:
import SwiftUI
/// -----------------------------------------------------------------
/// ListAndSelect
/// -----------------------------------------------------------------
protocol ListAndSelectItem: Identifiable {
var name: String { get set }
var value: Int { get set }
// For listView:
static var listTitle: String { get }
associatedtype ItemListView: View
func itemListView() -> ItemListView
// For detailView:
var detailTitle: String { get }
associatedtype DetailView: View
func detailView() -> DetailView
}
extension Array where Element: ListAndSelectItem {
func listAndSelect() -> some View {
return ListView(items: self, itemName: Element.listTitle)
}
}
struct ListView<Item: ListAndSelectItem>: View {
var items: [Item]
var itemName: String
var body: some View {
NavigationView {
List(items) { item in
NavigationLink(
destination: DetailView(item: item, index: String(item.value))
) {
VStack(alignment: .leading){
item.itemListView()
.font(.system(size: 15)) // Feasible that we should remove this
}
}
}
.navigationBarTitle(Text(itemName).foregroundColor(Color.black))
}
}
}
struct DetailView<Item: ListAndSelectItem>: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var item: Item
var index: String
var body: some View {
NavigationView(){
item.detailView()
}
.navigationBarTitle(Text(item.name).foregroundColor(Color.black))
.navigationBarItems(leading: Button(action: {
self.presentationMode.wrappedValue.dismiss()
}, label: { Text("<").foregroundColor(Color.black)}))
}
}
which means I can then just write:
struct Person: ListAndSelectItem {
var id = UUID()
var name: String
var value: Int
typealias ItemListView = PersonListView
static var listTitle = "People"
func itemListView() -> PersonListView {
PersonListView(person: self)
}
typealias DetailView = PersonDetailView
let detailTitle = "Detail Title"
func detailView() -> DetailView {
PersonDetailView(person: self)
}
}
struct PersonListView: View {
var person: Person
var body: some View {
Text("List View for \(person.name)")
}
}
struct PersonDetailView: View {
var person: Person
var body: some View {
Text("Detail View for \(person.name)")
}
}
struct ContentView: View {
let persons: [Person] = [
Person(name: "Jane", value: 1),
Person(name: "John", value: 2),
Person(name: "Jemima", value: 3),
]
var body: some View {
persons.listAndSelect()
}
}
which isn't bad but I feel I ought to be able to go further.
Having to write:
typealias ItemListView = PersonListView
static var listTitle = "People"
func itemListView() -> PersonListView {
PersonListView(person: self)
}
with
struct PersonListView: View {
var person: Person
var body: some View {
Text("List View for \(person.name)")
}
}
still seems cumbersome to me.
In each of my 6 cases I'd be writing very similar code.
I feel like I ought to be able to just write:
static var listTitle = "People"
func itemListView() = {
Text("List View for \(name)")
}
}
because that's the unique bit.
But that certainly won't compile.
And then the same for the Detail.
I can't get my head around how to simplify further.
Any ideas welcome?

The key to this is, if you want to use a view in a protocol then:
1) In the protocol:
associatedtype SpecialView: View
var specialView: SpecialView { get }
2) In the struct using the protocol:
var specialView: some View { Text("Special View") }
So in the situation of the question:
By changing my protocol to:
protocol ListAndSelectItem: Identifiable {
var name: String { get set }
var value: Int { get set }
// For listView:
static var listTitle: String { get }
associatedtype ListView: View
var listView: ListView { get }
// For detailView:
var detailTitle: String { get }
associatedtype DetailView: View
var detailView: DetailView { get }
}
I can now define Person as:
struct Person: ListAndSelectItem {
var id = UUID()
var name: String
var value: Int
static var listTitle = "People"
var listView: some View { Text("List View for \(name)") }
var detailTitle = "Person"
var detailView: some View { Text("Detail View for \(name)") }
}
which is suitable DRY and free of boilerplate!

Related

Selection in Sectioned List in SwiftUI

If I have a list with multiple sections of heterogenous type (say like a sidebar in a macOS app), how do I keep track of the $selection? Here's a simplified example :
struct A: Hashable {
let propA: Int
}
struct B: Hashable {
let propB: Int
}
struct ContentView: View {
let allAs = (1...10).map{A(propA: $0)}
let allBs = (1...5).map{B(propB: $0)}
#Binding var selectedA: A?
#Binding var selectedB: B?
var body: some View {
List() { // What goes in selected: ?
Section() {
ForEach(allAs, id: \.propA) {
Text(String($0.propA))
}
} header: {
Text("A Section")
}
Section() {
ForEach(allBs, id: \.propB) {
Text(String($0.propB))
}
} header: {
Text("B Section")
}
}
}
}
Is the only way around this to create a generic type or protocol to hold A and B?

Using protocol to capture SwiftUI view

I’m trying to write a protocol that requires conformers to have a View property, or a #ViewBuilder method that returns some View.
I want to have a re-useable composite view that can build different sub views based on what type of data needs to be displayed.
The protocol would look like this…
protocol RowView {
var leftSide: some View { get }
var rightSide: some View { get }
}
That way I could call something like this…
struct Example: RowView {
var id: Int
var leftSide: some View { … }
var rightSide: some View { … }
}
struct ContentView: View {
let rows: [RowView]
var body: some View {
VStack {
Foreach(rows, id: \.id) {
HStack {
$0.leftSide
$0.rightSide
}
}
}
}
}
You need to change the protocol to something like:
protocol RowView {
associatedtype LView: View
associatedtype RView: View
var leftSide: LView { get }
var rightSide: RView { get }
}
Also, use the concrete Example type in the content view instead of the protocol (the protocol you defined doesn't have id at all):
let rows: [Example]
Also! you can make the RowView to be identifiable as your need, So no need for id: \.id anymore:
protocol RowView: Identifiable
A working code:
protocol RowView: Identifiable {
associatedtype LView: View
associatedtype RView: View
var leftSide: LView { get }
var rightSide: RView { get }
}
struct Example: RowView {
var id: Int
var leftSide: some View { Text("Left") }
var rightSide: some View { Text("Right") }
}
struct ContentView: View {
let rows: [Example] = [
.init(id: 1),
.init(id: 2)
]
var body: some View {
VStack {
ForEach(rows) { row in
HStack {
row.leftSide
row.rightSide
}
}
}
}
}

SwiftUI: Model doesn't work with the TextField view

I have this simple code for my model:
import Foundation
class TaskListModel: ObservableObject
{
struct TodoItem: Identifiable
{
var id = UUID()
var title: String = ""
}
#Published var items: [TodoItem]?
//MARK: - intents
func addToList()
{
self.items!.append(TodoItem())
}
}
Then I use it in this view:
import SwiftUI
struct TasksListView: View {
#ObservedObject var model = TaskListModel()
var body: some View {
List {
Button("Add list", action: {
model.addToList()
})
ForEach(model.items!) { item in
TextField("Title", text: item.title)
}
.onMove { indexSet, offset in
model.items!.move(fromOffsets: indexSet, toOffset: offset)
}
.onDelete { indexSet in
model.items!.remove(atOffsets: indexSet)
}
}
}
}
struct TasksListView_Previews: PreviewProvider {
static var previews: some View {
TasksListView()
}
}
I can't seem to make this code work, I suspect the items array needs to be wrapped in #Binding property wrapper, but it already wrapped in #Published, so it puzzles me even more. Any help would be appreciated!
You have forgotten to create array for items
class TaskListModel: ObservableObject
{
struct TodoItem: Identifiable
{
var id = UUID()
var title: String = ""
}
#Published var items: [TodoItem] = [] // << here !!
// ...
}
and remove everywhere force-unwrap (!!)

View dismisses unintentionally in SwiftUI

My goal is to have an array of structs that when tapped on an individual item, a change is made and passed up to the parent view but doesn't automatically dismiss my child view. I'm really not sure why the child view is automatically dismissing and how to prevent it. Here is my code for the ContentView. Note that I have now updated the struct to Identifiable.
//
// ContentView.swift
// test
//
// Created by Kevin McQuown on 3/7/22.
//
import SwiftUI
struct Person: Identifiable {
var id = UUID()
var name: String = ""
}
struct ContentView: View {
#State private var patients: [Person]
init() {
var temp: [Person] = []
for index in 0 ..< 3 {
temp.append(Person(name: "\(index)"))
}
patients = temp
}
var body: some View {
NavigationView {
VStack(spacing: 40) {
ForEach($patients) { $patient in
NavigationLink("\(patient.name)") {
View2(patient: $patient)
}
}
}
}
}
}
struct View2: View {
#Binding var patient: Person
var body: some View {
NavigationLink("\(patient.name)") {
ChangeNameView(patient: $patient)
}
}
}
struct ChangeNameView: View {
#Binding var patient: Person
var body: some View {
Button {
patient.name = "New Name"
} label: {
Text("Set New Name")
}
}
}

SwiftUI MVVM simple concept confusion - advice needed

I'm struggling with some basic MVVM concepts in SwiftUI. I appreciate this is probably a simple question but my brain is frazzled I can't figure it out.
Here's my models/views/viewmodels etc.
import Foundation
struct Challenges {
var all: [Challenge]
init() {
all = []
}
}
struct Challenge: Identifiable, Codable, Hashable {
private(set) var id = UUID()
private(set) var name: String
private(set) var description: String
private(set) var gpxFile: String
private(set) var travelledDistanceMetres: Double = 0
init(name: String, description: String, gpxFile: String) {
self.name = name
self.description = description
self.gpxFile = gpxFile
}
mutating func addDistance(_ distance: Double) {
travelledDistanceMetres += distance
}
}
import SwiftUI
#main
struct ActivityChallengesApp: App {
var body: some Scene {
WindowGroup {
ChallengesView()
.environmentObject(ChallengesViewModel())
}
}
}
import SwiftUI
class ChallengesViewModel: ObservableObject {
#Published var challenges: Challenges
init() {
challenges = Challenges()
challenges.all = DefaultChallenges.ALL
}
func addDistance(_ distance: Double, to challenge: Challenge) {
challenges.all[challenge].addDistance(distance)
}
}
import SwiftUI
struct ChallengesView: View {
#EnvironmentObject var challengesViewModel: ChallengesViewModel
var body: some View {
NavigationView {
List {
ForEach(challengesViewModel.challenges.all) { challenge in
NavigationLink {
ChallengeView(challenge)
.environmentObject(challengesViewModel)
} label: {
VStack(alignment: .leading) {
Text(challenge.name)
Text("\(challenge.travelledDistanceMetres)")
}
}
}
}
.navigationTitle("Challenges")
}
}
}
import SwiftUI
struct ChallengeView: View {
var challenge: Challenge
#EnvironmentObject var challengesViewModel: ChallengesViewModel
init(_ challenge: Challenge) {
self.challenge = challenge
}
var body: some View {
VStack(alignment: .leading) {
Text(challenge.name)
Text("\(challenge.travelledDistanceMetres)")
}
.onTapGesture {
handleTap()
}
}
func handleTap() {
challengesViewModel.addDistance(40, to: challenge)
}
}
I understand the concepts but I'm confused as to what the ViewModel should be.
I feel like this is overkill, i.e. sending a model object to the view and the view model as an environment object. With this set up, I call the addDistance() function in the view model from within the view to make changes to the model.
ChallengeView(challenge)
.environmentObject(challengesViewModel)
Is it better to have a view model for the collection or one view model per model object?
This is the simplest version I could come up with.
I don't really understand the need for the challenges.all ? So I took it out.
I have
a struct for the single challenge
an observable class which is publishing the challenges array
instantiate this once with #StateObject and pass it down as you did
btw: You don't need explicit initializers for structs
this is it:
#main
struct ActivityChallengesApp: App {
// here you create your model once
#StateObject var challenges = ChallengesModel()
var body: some Scene {
WindowGroup {
ChallengesView()
.environmentObject(challenges)
}
}
}
struct Challenge: Identifiable, Codable, Hashable {
var id = UUID()
var name: String
var description: String
var gpxFile: String
var travelledDistanceMetres: Double = 0
mutating func addDistance(_ distance: Double) {
travelledDistanceMetres += distance
}
}
class ChallengesModel: ObservableObject {
#Published var challenges: [Challenge]
init() {
// Test data
challenges = [
Challenge(name: "Challenge One", description: "?", gpxFile: ""),
Challenge(name: "Challenge Two", description: "?", gpxFile: ""),
Challenge(name: "Last Challenge", description: "?", gpxFile: "")
]
}
func addDistance(_ distance: Double, to challenge: Challenge) {
// find the challenge and update it
if let i = challenges.firstIndex(where: {$0.id == challenge.id}) {
challenges[i].addDistance(distance)
}
}
}
struct ChallengesView: View {
#EnvironmentObject var challengesModel: ChallengesModel
var body: some View {
NavigationView {
List {
ForEach(challengesModel.challenges) { challenge in
NavigationLink {
ChallengeView(challenge: challenge)
.environmentObject(challengesModel)
} label: {
VStack(alignment: .leading) {
Text(challenge.name)
Text("\(challenge.travelledDistanceMetres)")
}
}
}
}
.navigationTitle("Challenges")
}
}
}
struct ChallengeView: View {
var challenge: Challenge
#EnvironmentObject var challengesModel: ChallengesModel
var body: some View {
VStack(alignment: .leading) {
Text(challenge.name)
Text("\(challenge.travelledDistanceMetres)")
}
.onTapGesture {
handleTap()
}
}
func handleTap() {
challengesModel.addDistance(40, to: challenge)
}
}