Modifying SwiftUI Views based on non-binary data / conditions? - swift

Question: I have a fairly large SwiftUI View that adds a country code based on my model data. My feeling is that I should extract a subview and then pass the country code in, then use a switch, but I just wanted to check I was not missing something and making this too complicated.
SwiftUI has a very nice method of dealing with two possible options based on a Bool, this is nice as it modifies a single View.
struct TestbedView_2: View {
var isRed: Bool
var body: some View {
Text("Bilbo").foregroundColor(isRed ? Color.red : Color.blue)
}
}
If on the other hand your model presents a none binary choice you can use a switch statement. This however returns an Independent View based on the case selected resulting in duplicate code.
struct TestbedView_3: View {
var index: Int
var body: some View {
switch(index) {
case 1:
Text("Bilbo").foregroundColor(Color.red)
case 2:
Text("Bilbo").foregroundColor(Color.green)
default:
Text("Bilbo").foregroundColor(Color.blue)
}
}
}

Here is more clean approach:
Only display text once - use function to get the color
Text("Bilbo").foregroundColor(self.getColor(index))
Create the getColor function
private func getColor(_ index : Int) -> Color {
switch index {
case 1: return Color.red
case 2: return Color.green
case 3: return Color.blue
default: return Color.clear
}
}
NOTE: I am using Color.clear as default case in the switch statement since it must be present

Related

SwiftUI / Combine subscribe to updates in multiple nested collections

I have a SummaryView with a Report as #State.
A Report is a protocol which includes some changes a user might want to make:
protocol Report {
var changeGroups: [ChangeGroup] { get set }
}
There are several kinds of reports; individual reports are implemented as a struct:
struct RealEstateReport: Report {
static let name = "Real Estate Report"
var changeGroups = [ChangeGroup]()
}
A ChangeGroup is a struct with (among other stuff) a human-readable summary and a handful of proposed changes:
struct ChangeGroup: Identifiable {
var summary: String
var proposedChanges = [ProposedChange]()
}
A ProposedChange is a class that represents one discrete change the app proposes to the user, which is enabled by default:
class ProposedChange: ObservableObject, Identifiable {
#Published var enabled = true
let summary: String
(In a detail view, enabled is bound to a Toggle so a user can flip each proposed change on and off.)
So a Report has many ChangeGroups which themselves have many ProposedChanges.
I'm trying to include some high level details on the SummaryView:
struct SummaryView: View {
#State var report: Report
var body: some View {
Text("Summary")
.foregroundColor(…) // ???
}
I want foregroundColor to be red, yellow, or green:
Red if enabled is false for all ProposedChanges in this Report
Green if enabled is true for all ProposedChanges in this Report
Yellow if enabled is mixed for different ProposedChanges in this Report
I've read a bit about Combine, and I think I need to create a new Combine subscription for each ChangeGroup, and map that to a new Combine subscription for each ProposedChange's enabled property, flatten the values when one changes, and check if they're all the same.
I'm a little lost on the exact syntax I'd use. And also it seems like structs don't publish changes in the same way (I guess since the structs are value vs. reference types).
How can I set the foregroundColor of the Text view based on the above logic?
Your issue is immediately solved if ProposedChange is a struct and not a class. Unless its instances have their own life cycle, then they are just holders of value, so should be semantically a struct.
The reason your issue is solved is because mutating a property of a struct mutates the struct, so SwiftUI knows to recompute the view, whereas with a class you need to subscribe to changes.
Assuming ProposedChange is a struct:
struct ProposedChange {
var enabled = true
var summary: String
}
the following should work:
struct SummaryView: View {
#State var report: Report
var body: some View {
Text("Summary")
.foregroundColor(summaryColor)
}
var summaryColor: Color {
let count = report.changeGroups.flatMap { $0.proposedChanges }
.map { ($0.enabled ? 1 : 0, 1) }
.reduce((0, 0), { ($0.0 + $1.0, $0.1 + $1.1) })
if count.0 == count.1 { return Color.green }
else if count.0 == 0 { return Color.red }
else { return Color.yellow }
}
}
I ended up mapping all the enabled flags to their publisher, combining them all using the CombineLatest operator, and then recalculating when the value changes:
class ViewModel: ObservableObject {
enum BoolState {
case allTrue, allFalse, mixed
}
#Published var boolState: BoolState?
private var report: Report
init(report: Report) {
self.report = report
report
.changeGroups // [ChangeGroup]
.map { $0.proposedChanges } // [[ProposedChange]]
.flatMap { $0 } // [ProposedChange]
.map { $0.$enabled } // [AnyPublisher<Bool, Never>]
.combineLatest() // AnyPublisher<[Bool], Never>
.map { Set($0) } // AnyPublisher<Set<Bool>, Never>
.map { boolSet -> BoolState in
switch boolSet {
case [false]:
return .allFalse
case [true]:
return .allTrue
default:
return .mixed
}
} // AnyPublisher<BoolState, Never>
.assign(to: &$boolState)
}
}
Note: .combineLatest() is not part of Combine but it's just an extension I wrote that iterates each pair of publishers in the array and calls them iteratively, like first.combineLatest(second).combineLatest(third) etc. If you need something more robust than this, it looks like the CombineExt project has a CombineLatestMany extension with several options.
At this point my view just does a #ObservedObject var viewModel: ViewModel and then uses viewModel.boolState in the body. Whenever any of the enabled flags change for any reason, the view updates successfully!

Swift switch case simplification

I am currently creating an iOS app based on the math card game "Set", and I am running into a problem simplifying the switch case statement. My goal is to avoid an ugly series of nested switches in my view. I come from a background of OOP and normally I would store references to an object and call them later where I want; however, I am trying to be functional. Below is a some code describing roughly what I would like to do.
struct CardView: View {
var card: SetGame<CardShape, CardShading, CardColor>.Card
var cardShape: some Shape {
switch card.shape {
case .diamond:
return Diamond()
case .squiggle:
return Squiggle()
case .oval:
return Oval()
}
}
var cardShading: some ViewModifier {
switch card.shading {
case .open:
return .stroke()
case .striped:
return .stripe()
case .solid:
return .fill()
}
}
var cardColor: Color
{
switch card.color {
case .red:
return Color.red
case .green:
return Color.green
case .purple:
return Color.purple
}
}
var body: some View {
VStack {
ForEach(0..<card.count) { _ in
cardShape.cardShading
}
.aspectRatio(contentAspectRatio, contentMode: .fit)
}
.foregroundColor(cardColor)
.padding()
.cardify(selected: card.selected)
}
// MARK: Drawing Constants
let contentAspectRatio: CGSize = CGSize(width: 2, height: 1)
}
Now the above code does not work for obvious reasons, it is more about conveying what I am trying to accomplish. The easy brute force solution I see would be to have three nested switch/case statements, but I have to believe there is another way. I tried using type erasure in card shape doing something like this...
var cardShape: some View {
switch card.shape {
case .diamond:
return AnyView(Diamond())
case .squiggle:
return AnyView(Squiggle())
case .oval:
return AnyView(Oval())
}
}
and while this does "work", it is no longer a shape and I lose access to my shape modifiers (ie. fill, stripe, stroke). I am pretty sure I won't be able to store my ViewModifiers and call them at a different place, so I am sort of expecting there to be one switch/case inside of my var body. If someone has a completely different approach or if I am just doing something stupid, feel free to let me know! I am trying to learn the most "Swiftish" way of doing things. Thanks in advance!

Function declares an opaque return type, but the return statements in its body do not have matching underlying types [duplicate]

Ok, SwiftUI was released this week so we're all n00bs but... I have the following test code:
var body: some View {
switch shape {
case .oneCircle:
return ZStack {
Circle().fill(Color.red)
}
case .twoCircles:
return ZStack {
Circle().fill(Color.green)
Circle().fill(Color.blue)
}
}
}
which produces the following error:
Function declares an opaque return type, but the return statements in its body do not have matching underlying types
This happens because the first ZStack is this type:
ZStack<ShapeView<Circle, Color>>
and the second is this type:
ZStack<TupleView<(ShapeView<Circle, Color>, ShapeView<Circle, Color>)>>
How do I deal with this in SwiftUI? Can they be flattened somehow or be made to conform to the same type.
One way to fix this is to use the type eraser AnyView:
var body: some View {
switch shape {
case .oneCircle:
return AnyView(ZStack {
Circle().fill(Color.red)
})
case .twoCircles:
return AnyView(ZStack {
Circle().fill(Color.green)
Circle().fill(Color.blue)
})
}
}
UPDATE
I add the following to answer the commenters who are asking why this is needed.
One commenter says
ZStack is still a View, right?
Actually, no. ZStack by itself is not a View. ZStack<SomeConcreteView> is a View.
The declaration of ZStack looks like this:
public struct ZStack<Content> : View where Content : View
ZStack is generic. That means that ZStack by itself is not a type. It is a “type constructor”.
The idea of a type constructor is not usually discussed in the Swift community. A type constructor is, essentially, a function that runs at compile time. The function takes one or more types as arguments and returns a type.
ZStack is a type constructor that takes one argument. If you ‘call’ ZStack repeatedly with different arguments, it returns different answers. This is what Robert Gummesson shows in his question:
This happens because the first ZStack is this type:
ZStack<ShapeView<Circle, Color>>
and the second is this type:
ZStack<TupleView<(ShapeView<Circle, Color>, ShapeView<Circle, Color>)>>
In the first case, the program ‘calls’ ZStack with the argument ShapeView<Circle, Color> and gets back a type as the answer. In the second case, the program ‘calls’ ZStack with a different argument, TupleView<(ShapeView<Circle, Color>, ShapeView<Circle, Color>)>, and so it gets back a different type as the answer.
The declaration var body: some View says that the body method returns a specific, concrete type (to be deduced by the compiler) that conforms to the View protocol. Since the two ‘calls’ to ZStack return different concrete types, we must find a way to convert them both to a single common type. That is the purpose of AnyView. Note that AnyView is not generic, which is to say, it is not a type constructor. It is just a plain type.
You can also use Group which is logical container so won't change anything visual.
var body: some View {
Group {
switch shape {
case .oneCircle:
return ZStack {
Circle().fill(Color.red)
}
case .twoCircles:
return ZStack {
Circle().fill(Color.green)
Circle().fill(Color.blue)
}
}
}
}
I know this is an old question. But i stumbled upon it, and the now the correct way to do it, would be with a #ViewBuilder annotation (notice the missing return):
#ViewBuilder var body: some View {
switch shape {
case .oneCircle:
ZStack {
Circle().fill(Color.red)
}
case .twoCircles:
ZStack {
Circle().fill(Color.green)
Circle().fill(Color.blue)
}
}
}
Or in this specific case, factor out the ZStack:
var body: some View {
ZStack {
switch shape {
case .oneCircle:
Circle().fill(Color.red)
case .twoCircles:
Circle().fill(Color.green)
Circle().fill(Color.blue)
}
}
}

SwiftUI strange behavior when moving items between sections in a List

so I've been trying to make a component using swiftUI that allows you to move items in a List between sections.
I prepared an example with two sections: "First List" and "Second List". Whenever you tap on an item it swaps sections. Here's a screenshot:
When I tap on "First List: 1", it correctly moves to the second section:
However, its name should now be changed to "Second List: 1" because of the way I named the elements in the sections (see code below). So that's strange. But it gets stranger:
When I now tap on "First List: 1" in the second section this happens:
It doesn't properly swap back. It just gets duplicated, but this time the name of the duplicate is actually correct.
Considering the code below I don't understand how this is possible. It seems that swiftUI somehow reuses the item, even though it re-renders the view? It also seems to reuse the .onTapGesture closure, because the method that's supposed to put the item back into the first section is never actually called.
Any idea what's going on here? Below is a fully working example of the problem:
import SwiftUI
import Combine
struct TestView: View {
#ObservedObject var viewModel: ViewModel
class ViewModel: ObservableObject {
let objectWillChange = PassthroughSubject<ViewModel,Never>()
public enum List {
case first
case second
}
public var first: [Int] = []
public var second: [Int] = []
public func swap(elementWithIdentifier identifier: Int, from list: List) {
switch list {
case .first:
self.first.removeAll(where: {$0 == identifier})
self.second.append(identifier)
case .second:
print("Called")
self.second.removeAll(where: {$0 == identifier})
self.first.append(identifier)
}
self.objectWillChange.send(self)
}
init(first: [Int]) {
self.first = first
}
}
var body: some View {
NavigationView {
List {
Section(header: Text("First List")) {
ForEach(self.viewModel.first, id: \.self) { id in
Text("First List: \(id)")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture {
self.viewModel.swap(elementWithIdentifier: id, from: .first)
}
}
}
Section(header: Text("First List")) {
ForEach(self.viewModel.second, id: \.self) { id in
Text("Second List: \(id)")
.onTapGesture {
self.viewModel.swap(elementWithIdentifier: id, from: .second)
}
}
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle(Text("Testing"))
}.environment(\.editMode, .constant(EditMode.active))
}
}
struct TestView_Preview: PreviewProvider {
static var previews: some View {
TestView(viewModel: TestView.ViewModel(first: [1, 2, 3, 4, 5]))
}
}
The only way I've solved this is to prevent diffing of the list by adding a random id to the list. This removes animations though, so looking for a better solution
List {
...
}
.id(UUID())
Removing the sections also fixes this, but isn't a valid solution either
I've found myself in a similar situation and have a found a more elegant workaround to this problem. I believe the issue lies with iOS13. In iOS14 the problem no longer exists. Below details a simple solution that works on both iOS13 and iOS14.
Try this:
extension Int {
var id:UUID {
return UUID()
}
}
and then in your ForEach reference \.id or \.self.id and not \.self i.e like so in both your Sections:
ForEach(self.viewModel.first, id: \.id) { id in
Text("First List: \(id)")
.onTapGesture {
self.viewModel.swap(elementWithIdentifier: id, from: .first)
}
}
This will make things work. However, when fiddling around I did find these issues:
Animations were almost none existent in iOS14. This can be fixed though.
In iOS13 the .listStyle(GroupedListStyle()) animation looks odd. Remove this and animations look a lot better.
I haven't tested this solution on large lists. So be warned around possible performance issues. For smallish lists it works.
Once again, this is a workaround but I think Apple is still working out the kinks in SwiftUI.
Update
PS if you use any onDelete or onMove modifiers in iOS14 this adds animations to the list which causes odd behaviour. I've found that using \.self works for iOS14 and \.self.id for iOS13. The code isn't pretty because you'll most likely have #available(iOS 14.0, *) checks in your code. But it works.
I don't know why, but it seems like your swap method does something weird on the first object you add, because if the second one works, maybe you've lost some instance.
By the way, do you need to removeAll every time you add a new object in each list?
public function interchange (identifier elementWithIdentifier: Int, from list: List) {
switch list {
case .first:
self.first.removeAll (where: {$ 0 == identifier})
self.second.append (identifier)
case .second:
print ("Called")
self.second.removeAll (where: {$ 0 == identifier})
self.first.append (identifier)
}
self.objectWillChange.send (self)
}
maybe your problem is in this function, everything looks great.
The fix is simple - use default ObservableObject publishers (which are correctly observed by ObservedObject wrapper) instead of Combine here, which is not valid for this case.
class ViewModel: ObservableObject {
public enum List {
case first
case second
}
#Published public var first: [Int] = [] // << here !!
#Published public var second: [Int] = [] // << here !!
public func swap(elementWithIdentifier identifier: Int, from list: List) {
switch list {
case .first:
self.first.removeAll(where: {$0 == identifier})
self.second.append(identifier)
case .second:
print("Called")
self.second.removeAll(where: {$0 == identifier})
self.first.append(identifier)
}
}
init(first: [Int]) {
self.first = first
}
}
Tested with Xcode 13.3 / iOS 15.4
*and even with animation wrapping swap into withAnimation {}

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.