Home > Net >  SwiftUI and MVVM: Using Buttons to Sort Lists
SwiftUI and MVVM: Using Buttons to Sort Lists

Time:11-29

I'm building an app that uses buttons to sort a variety of lists.

I'm trying my best to follow the MVVM design pattern as DRYly as possible. There are several Views that display lists and so I'd like to be able to reuse button structs in a number of views, and connect them to a number of View Models

The way I currently have this setup the code Builds but the lists do not change when a new button is pressed. Any ideas?

A sample project with the issue can be downloaded from GitHub here: https://github.com/sans-connaissance/SOQ-BetterButtons

Here's the code for the View:

import SwiftUI

struct ContentView: View {
    
    @StateObject private var vm = ContentViewModel()
    
    
    var body: some View {
        
        VStack {
            HStack {
                
                SortButton(name: .arrayOne, bools: $vm.bools)
                    .onChange(of: vm.bools){ _ in vm.getArray()}
                SortButton(name: .arrayTwo, bools: $vm.bools)
                    .onChange(of: vm.bools){ _ in vm.getArray()}
                SortButton(name: .arrayThree, bools: $vm.bools)
                    .onChange(of: vm.bools){ _ in vm.getArray()}
            
            }
            
            List {
                ForEach(vm.contentArray, id: \.self) { content in
                    Text(content.self)
                }
                
            }
        }
        .onAppear {vm.setButtons()}
        .onAppear {vm.getArray()}
        
    }
}

Here's the code for the buttons

import SwiftUI

struct SortButton: View {
    
    var name: Select
    @Binding var bools: [String : Bool]

    var body: some View {
        Button {
            func show(button: Select) {
                Select.allCases.forEach { button in
                    bools[button.rawValue] = false
                }
                
                bools[button.rawValue] = true
            }
            
        } label: {
            Text(name.rawValue)
        }
        
    }
}

enum Select: String, CaseIterable {
    case arrayOne = "arrayOne"
    case arrayTwo = "arrayTwo"
    case arrayThree = "arrayThree"
}

And finally the ViewModel for this example.

import Foundation

class ContentViewModel: ObservableObject {
    
    @Published var contentArray = [String]()
    @Published var bools = [String : Bool]()
    
    
    private let arrayOne = ["One", "Two", "Three"]
    private let arrayTwo = ["Four", "Five", "Six"]
    private let arrayThree = ["Seven", "Eight", "Nine"]
    
    
    func setButtons() {
        
        Select.allCases.forEach { button in
            bools[button.rawValue] = false
        }
        
        bools["arrayOne"] = true
    }
    
    
    func getArray() {
        
        if bools["arrayOne"]! {
            contentArray.removeAll()
            contentArray.append(contentsOf: arrayOne)
        }
        
        if bools["arrayTwo"]! {
            contentArray.removeAll()
            contentArray.append(contentsOf: arrayTwo)
        }
        
        if bools["arrayThree"]! {
            contentArray.removeAll()
            contentArray.append(contentsOf: arrayThree)
        }
        
    }
}

Link to example project on GitHub: https://github.com/sans-connaissance/SOQ-BetterButtons

Thanks for taking a look!!

CodePudding user response:

I think you are taking MVVM too far in your code in trying to learn it and are needlessly complicating things. This code is pretty fragile and is not easily changed. I did get your code working, and I want to go through it for a bit.

I cleaned up your enum and that is commented in code. I also changed your var bools to be of type [Select:Bool]. This allows you to use the enum cases themselves, instead of the raw values. The compiler can then help you because it knows what is a valid Select case and what is not. When you use String, it has no idea that you meant a "string which is the raw value of a Select case. The greatly reduces errors.

Also, avoid having so much white space in your code. Try to have logical groupings that you separate with a space. Too much white space makes your code difficult to read because you end up scrolling so much just to see it all.

Where your code was actually failing was in the SortButton itself. In the Button, the action you told the Button to take was to define your func show(button:). Therefore, this function was only defined briefly when any of the buttons were used, and then was gone. Frankly, I can't believe the Xcode allowed this at all without raising some error. You wan to define the function outside of your var body and call it from within the button's action.

Lastly, and this is not something I can really change in your code, you should avoid using a ! to force unwrap optionals. It works here simply because you have a small fixed amount of constant arrays, but as things grow more complex this becomes a fatal error.

struct ContentView: View {
    
    @StateObject private var vm = ContentViewModel()

    var body: some View {
        VStack {
            HStack {
                SortButton(name: .arrayOne, bools: $vm.bools)
                    .onChange(of: vm.bools){ _ in vm.getArray()}
                SortButton(name: .arrayTwo, bools: $vm.bools)
                    .onChange(of: vm.bools){ _ in vm.getArray()}
                SortButton(name: .arrayThree, bools: $vm.bools)
                    .onChange(of: vm.bools){ _ in vm.getArray()}
            }
            
            List {
                ForEach(vm.contentArray, id: \.self) { content in
                    Text(content.self)
                }
            }
        }
        .onAppear {
            vm.setButtons()
            vm.getArray()
        }
    }
}

struct SortButton: View {
    
    var name: Select
    @Binding var bools: [Select : Bool]

    var body: some View {
        Button {
            show(button: name)
        } label: {
            Text(name.rawValue)
        }
    }
    //This is defined outside of the body, and called from the button.
    func show(button: Select) {
        Select.allCases.forEach { button in
            bools[button] = false
        }
        bools[button] = true
    }
}

enum Select: String, CaseIterable {
    // when you declare an enum to be of type String, you get the string version of the name for free
    // you don't need case arrayOne = "arrayOne". Also, once you remove the = ""
    // you can use one case statement to define them all. The only time you need the = "" is when
    // you want to change the default rawValue such as case arrayOne = "Array One"
    case arrayOne, arrayTwo, arrayThree
}

class ContentViewModel: ObservableObject {
    
    @Published var contentArray = [String]()
    @Published var bools = [Select : Bool]()

    private let arrayOne = ["One", "Two", "Three"]
    private let arrayTwo = ["Four", "Five", "Six"]
    private let arrayThree = ["Seven", "Eight", "Nine"]
    
    func setButtons() {
        Select.allCases.forEach { button in
            bools[button] = false
        }
        bools[.arrayOne] = true
    }
    
    
    func getArray() {
        // if you just set contentArray equal to one of the other arrays, you
        // get the same result as the .removeAll and the .append(contentsOf:)
        if bools[.arrayOne]! { contentArray = arrayOne }
        if bools[.arrayTwo]! { contentArray = arrayTwo }
        if bools[.arrayThree]! { contentArray = arrayThree }
    }
}

I also did a quick run through of how I could condense and simplify your code further. There is still more to be done, but this is a bit of an artificial exercise. With MVVM, you want to separate the model logic from the view and place that in the view, but you should have view logic to display the view model's data. As much as the view model hides the model logic, the view should be able to handle different view models and display the data consistently. That is the essence of reusability.

Also, you will notice I removed the separate SortButton calls in ContentView and used a ForEach. This is a good example of DRY in action and it scales easily as you add Select cases.

One area this code can be improved is a better mechanism in SortButton to get 'ContentViewModel' to update. I just passed in ContentViewModel, but this could be further simplified.

struct ContentView: View {
    @StateObject private var vm = ContentViewModel()

    var body: some View {
        VStack {
            HStack {
                ForEach(Select.allCases, id: \.self) { name in
                    SortButton(name: name, vm: vm)
                }
            }
            List {
                ForEach(vm.contentArray, id: \.self) { content in
                    Text(content.self)
                }
            }
        }
    }
}

struct SortButton: View {
    var name: Select
    let vm: ContentViewModel
    
    var body: some View {
        Button {
            vm.updateContentArray(select: name)
        } label: {
            Text(name.rawValue)
        }
    }
}

enum Select: String, CaseIterable {
    case arrayOne, arrayTwo, arrayThree
}

class ContentViewModel: ObservableObject {
    
    @Published var contentArray: [String]

    private let arrayOne = ["One", "Two", "Three"]
    private let arrayTwo = ["Four", "Five", "Six"]
    private let arrayThree = ["Seven", "Eight", "Nine"]
    
    init() {
        contentArray = arrayOne
    }
    
    func updateContentArray(select: Select) {
        switch select {
        case .arrayOne:
            contentArray = arrayOne
        case .arrayTwo:
            contentArray = arrayTwo
        case .arrayThree:
            contentArray = arrayThree
        }
    }
}

CodePudding user response:

Your Button in SortButton is declaring a func not calling a function.

So to get your code working just adjust that part.

struct SortButton: View {
    
    var name: Select
    @Binding var bools: [String : Bool]

    var body: some View {
        Button {
            show(button: name)
            
        } label: {
            Text(name.rawValue)
        }
        
    }
    func show(button: Select) {
        Select.allCases.forEach { button in
            bools[button.rawValue] = false
        }
        
        bools[button.rawValue] = true
    }
}

As far as reducing your code. I would put the arrays with the enum for simplicity

//Control the options and their value/array all in one spot
enum Select: String, CaseIterable, Identifiable {
    var id: String{
        self.rawValue
    }
    case arrayOne = "arrayOne"
    case arrayTwo = "arrayTwo"
    case arrayThree = "arrayThree"
    
    func array() -> [String]{
        switch self {
        case .arrayOne:
            return ["One", "Two", "Three"]
        case .arrayTwo:
            return ["Four", "Five", "Six"]
        case .arrayThree:
            return ["Seven", "Eight", "Nine"]
        }
    }
}

Then you can use Select.allCases to create buttons

Use a single variable @Published var selection: Select = .arrayOne to hold the selection

and use the selection and the enum array() like this vm.selection.array() to keep your screen up to date.

import SwiftUI
struct CommonListView: View {
    @StateObject private var vm =  CommonListViewModel()
    var body: some View {
        VStack {
            HStack {
                //Automatically update the case buttons when you add cases to the enum
                ForEach(Select.allCases, content: { option in
                    SortButton(name: option, selection: $vm.selection)
                })
            }
            //Automatically update the list when the selection is updated
            List {
                ForEach(vm.selection.array(), id: \.self) { content in
                    Text(content.self)
                }
            }
        }
    }
}

struct SortButton: View {
    var name: Select
    @Binding var selection: Select
    
    var body: some View {
        Button {
            //Set the selection
            selection = name
            
        } label: {
            Text(name.rawValue)
        }
        
    }
}

//Simplify the ViewModel
class CommonListViewModel: ObservableObject {
    @Published var selection: Select = .arrayOne
}

struct CommonListView_Previews: PreviewProvider {
    static var previews: some View {
        CommonListView()
    }
}
  • Related