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()
}
}