#State var not updating View as expected - swiftui

I have the view below which instantiates a row of buttons, which I intend to make selectable (only one at a time).
//#State var currentSelectedIndex = 0
//var rooms: [Room]
ScrollView(.horizontal, showsIndicators: false, content: {
HStack {
ForEach(rooms.indices) { roomIndex in
Button(action: {
currentSelectedIndex = roomIndex
}) {
UserSelectionIcon(room: rooms[roomIndex], selected: roomIndex == currentSelectedIndex)
.padding(10)
}
}
}
})
When one is selected, it should show a blue circle around it. This is the line in my UserSelectionIcon object referenced above that should do this;
//#State var selected: Bool = false
Circle()
.strokeBorder(selected ? Color.blue.opacity(0.7) : Color.clear, lineWidth: 2)
This line does work, as the first index is selected and showing the blue circle. However, clicking any of the buttons does not reveal the blue circle around them. The currentSelectedIndex variable is being updated as expected.

In the UserSelectionIcon you have to use Bindings to access the currentSelectedIndex instead of States, as States won't update if you change a value in another view.
Therefore you have to safe the Bool 'selected' as Binding in your UserSelectionIcon view. Change this:
#State var selected: Bool = false
to this:
#Binding var selected: Bool
and than just refactor this call:
UserSelectionIcon(room: rooms[roomIndex], selected: roomIndex == currentSelectedIndex)
to this one:
UserSelectionIcon(room: rooms[roomIndex], selected: Binding(get: { roomIndex == currentSelectedIndex }, set: {_,_ in }))
Now you're wrapping the comparison of the two indices into a binding which will all the time represent the latest comparison of both indices. If you're passing just the comparison then it's like a one-time-value which won't refresh it's result if one of the indices is updated. But now the comparison is reevaluated every time you access the binding when selecting which color should be chosen for the stroke border.
Alternative Solution:
You should refactor this:
UserSelectionIcon(room: rooms[roomIndex], selected: roomIndex == currentSelectedIndex)
to this (to pass the State variable):
UserSelectionIcon(room: rooms[roomIndex], currentSelectedIndex: currentSelectedIndex)
and in your UserSelectionIcon you should to refactor this line:
#State var selected: Bool = false
to:
#Binding var currentSelectedIndex: Bool
and every icon should save it's index itself to compare it later:
var roomIndex: Int
then you can compare these two indices when displaying the border.
.strokeBorder(currentSelectedIndex == roomIndex ? Color.blue.opacity(0.7) : Color.clear, lineWidth: 2)

Related

SwiftUI #Published var inconsistently updating views

I am working on a simple Reversi app built using SwiftUI. Recently I was updating my app and noticed a bug with the UI not updating to match the game state.
Here is a screenshot of an in-progress game:
Here is a screenshot of the board after I press the undo button:
In the second screenshot, the middle-rightmost white piece should be black. I verified that the game state was correct by printing the board in a didSet block. For the first screenshot, this was the output (the array of pieces shown below corresponds to the pieces on the board, going left to right, then bottom to top):
[..., Optional(Dueling_Disks.Piece(isLight: false, id: 434BCE7B-D1B6-45E8-81AB-15F76007F76A)), Optional(Dueling_Disks.Piece(isLight: true, id: 63070399-84AB-43EE-9D03-BA21D64E0399)), ...,
Optional(Dueling_Disks.Piece(isLight: false, id: 9BBD7C56-5DE4-4249-8EC3-870AD8F9FBB1)), Optional(Dueling_Disks.Piece(isLight: false, id: D870C5F2-AF0F-4667-8CF1-0F7D81A82D17)), Optional(Dueling_Disks.Piece(isLight: true, id: 2C92104A-12F9-487F-A0BF-60ECEF8BF4B7)), ...,
Optional(Dueling_Disks.Piece(isLight: true, id: 4F120B09-C0BE-4993-8324-43013D95083B)), Optional(Dueling_Disks.Piece(isLight: true, id: DD3316A7-FAF9-4AB3-853A-F1750856E430)), Optional(Dueling_Disks.Piece(isLight: true, id: 8796F231-BDEA-4930-AA1C-D3084E6D6C98)), ...]
This means that this output would be: [black, white, (next row up) black, black, white, (next row up) white, white, white], which is correct and matches the screenshot. After pressing undo, here was the output:
[..., Optional(Dueling_Disks.Piece(isLight: false, id: 434BCE7B-D1B6-45E8-81AB-15F76007F76A)), Optional(Dueling_Disks.Piece(isLight: true, id: 63070399-84AB-43EE-9D03-BA21D64E0399)), ...,
Optional(Dueling_Disks.Piece(isLight: false, id: 9BBD7C56-5DE4-4249-8EC3-870AD8F9FBB1)), Optional(Dueling_Disks.Piece(isLight: false, id: D870C5F2-AF0F-4667-8CF1-0F7D81A82D17)), Optional(Dueling_Disks.Piece(isLight: false, id: 28FB5783-F0C2-426C-A03F-C5FCF3D342BA)), ...,
Optional(Dueling_Disks.Piece(isLight: true, id: 4F120B09-C0BE-4993-8324-43013D95083B)), Optional(Dueling_Disks.Piece(isLight: false, id: 22190EEE-D0C3-4E3F-AE18-F6577917CFD6)), ...]
This mean the output would be: [black, white, (next row up) black, black, black, (next row up) white, black].
Clearly, the SwiftUI views do not fully match the change (with the middle-rightmost staying white instead of being black as the state dictates).
For background, I have an ObservableObject that stores the game state and publishes changes to the views:
class Game: ObservableObject {
#Published var board = Board()
#Published var times = GameState.times
var pieces: [Piece] {
board.pieces.filter({ $0 != nil}) as! [Piece]
}
}
Both board and times are Structs (and are thus value-types).
I then initialize it in my GamesView:
struct GamesView: View {
#StateObject private var game = Game()
var body: some View {
NavigationView {
GameView()
}
.environmentObject(game)
}
}
My GamView just contains a BoardView (and some other UI views I omitted in this post):
struct GameView: View {
var body: some View {
BoardView()
}
}
And the BoardView just contains a grid of 64 SquareViews:
// SizeRange is 1...8
struct BoardView: View {
var body: some View {
VStack(alignment: .center, spacing: 0) {
ForEach(SizeRange.reversed(), id: \.self) { row in
HStack(spacing: 0) {
ForEach(SizeRange, id: \.self) { column in
SquareView(column: column, row: row)
}
}
}
}
}
}
Finally, the SquareView will display the correctly colored piece (or none if there is no piece on the board):
struct SquareView: View {
#EnvironmentObject private var game: Game
#State var column: Int
#State var row: Int
var body: some View {
let square = Square(column: column, row: row)
let animation: Animation = .linear(duration: 0.3)
ZStack {
Button {
} label: {
Text("")
}
.disabled(game.board.gameOver)
if let piece = game.board.pieces[square] {
PieceView(isLight: piece.isLight)
.frame(width: boardLength / 16, height: boardLength / 16)
.transition(.opacity.animation(animation))
.animation(animation, value: game.board)
.zIndex(1)
}
}
// .onChange(of: game.board.pieces) { _ in
// print("PIECE", row, column)
// }
}
}
I also used the above commented out .onChange function to see if views were getting the update. What was really odd is when I placed a piece, all squares would give output but when I pressed undo, only some of the squares would give output (and it appears to be random for each launch of the app).
I was able to fix the issue by changing the code in my Game object to this:
#Published var board = Board() {
willSet {
objectWillChange.send()
}
}
Which from my understanding should do the same thing as just having an #Published variable.
Any help is very appreciated, this is really stumping me since I can't think of what might be causing the views to not reflect the data. Thanks so much for any help, and I am happy to give any other info if needed!
First thing I notice is you have #StateObject in a View that does not do anything with any properties of the game in the body func when it changes so there is no reason to have it in that View because the body is being called pointlessly every time game is updated. Easy fix is to make it a let above the struct or if globals aren't your thing you could put in a singleton struct.
Second thing I notice is misuse of ForEach. You shouldn't compute reversed because that will slow down body and you shouldn't use id:\.self because that will break SwiftUI's structural identity (See Demystify SwiftUI WWDC 2021). You can however use ForEach on constant data and the syntax for that is ForEach(0..<10). Note you can not dynamically add a row to the board game this way or it'll crash.
Third, the use of #State in SquareView is incorrect. #State is declaring a state of truth so it must have a default value. I think those properties should simply be lets. When a View is init with different lets from last time, body is called, which is part of SwiftUI’s magic.
I think it would be a better architecture to the board places 2D array in your data model and doing a ForEach over it. Rather than doing a ForEach over indices and then looking up the data.

SwiftUI List row with multiple "push" style elements

I have a design to create a tableview like row setup, where each row has a top name and a number of details in the cell. If the name is tapped, a new screen is pushed (not presented as a sheet), if the rest of the row is tapped, then another screen is pushed.
What's the best way to accomplish this?
I explored a number of ways, e.g. nesting NavigationLinks, nesting a Button within a NavigationLink (the button has a highPriorityGesture).. but no good solution so far.
#State pushNumberView : Bool = false
#State pushNameView : Bool = false
list {
forEach(your_array) { item in
CellView(
numberNavigation: $pushNumberView,
nameNavigation: $pushNameView)
}
}
// MARK: Navigation Links
Group{
NavigationLink(
destination:NameView(),
isActive: $pushNameView
){EmptyView()}
NavigationLink(
destination:NumerView()
isActive: $pushNumberView
){EmptyView()}
}
CellView :
struct CellView : View {
#Binding numberNavigation : Bool
#Binding nameNavigation : Bool
var body: some View {
Text("name")
.onTapGesture{
nameNavigation = true
}
Text("number")
.onTapGesture{
numberNavigation = true
}
}
}

How do I make a switch.toggle() take effect within a ForEach loop?

I am trying to alternate at the background color of a List/ForEach using a #State var and toggling it on each repetition. The result is alway that the last color behind the entire List. I have set a breakpoint a Text view inside the ForEach and executing it, I see a stop once per item in the input array, then a display on the screen as expected (i.e. every second row is red and the rest are blue). Then, for some reason, we iterate through the code again, one for each item and leave the loop with the background color of all rows being blue.
The code below is a simplified version of my original problem, which iterates over a Realm Results and is expected to handle a NavigationLink rather than the Text-view and handle deleting items as well.
struct ContentView: View {
let array = ["olle", "kalle", "ville", "valle", "viktor"]
#State var mySwitch = false
var body: some View {
List {
ForEach(array, id: \.self) { name in
Text(name)
.onAppear() {
mySwitch.toggle()
print("\(mySwitch)")
}
.listRowBackground(mySwitch ? Color.blue : Color.red)
}
}
}
}
It because of #State var mySwitch = false variable is attached with all row of ForEach so whenever your mySwitch var change it will affect your all row.
So if you want to make some alternative way, you can use the index of item and check whether your number is even or not and do your stuff according to them.
Demo:
struct ContentView: View {
let array = ["olle", "kalle", "ville", "valle", "viktor"]
#State var mySwitch = false
var body: some View {
List {
ForEach(array.indices, id: \.self) { index in
Text(array[index])
.onAppear() {
mySwitch.toggle()
print("\(mySwitch)")
}
.listRowBackground(index.isMultiple(of: 2) ? Color.blue : Color.red)
}
}
}
}

SwiftUI - State without an initial value

I have a state declared in my view
#State var selected: Bool = false
#State var selectedPerson: Person
When a "Person" is selected from a view there is a callback that sets the "selected" state to true, and sets the person that is selected:
MapView(viewModel: MapViewModel(people: viewModel.people)) { value in
selectedPerson = viewModel.getPersonWith(name: value.title)
selected = true
}
When all of this is set, it triggers a NavigationLink like this:
NavigationLink(destination: PersonDetailsView(viewModel: PersonViewModel(person: selectedPerson)), isActive: self.$selected) {
EmptyView()
}.hidden()
Everything works fine, but I have to set a default value to the "selectedPerson" state before the struct is initialized, even tho there is no person selected. Is there a way to bypass this, without giving the "selectedPerson" state a default "dummy" value.
Thanks :)
You need to make initial state optional, like
#State var selectedPerson: Person?
but as well you have to handle this optional in all places that use it, like
if nil != selectedPerson {
NavigationLink(destination:
PersonDetailsView(viewModel: PersonViewModel(person: selectedPerson!)), // << !!
isActive: self.$selected) {
EmptyView()
}.hidden()
}

How to initialize a binding Bool value in a SwiftUI view

Revised Example
Based on the thoughtful response from #pawello2222 I received in the comments below I have revised an example to demonstrate the issue I am wrestling with.
In order to demonstrate the issue I am having I am using 2 views a parent and a child. In my code code the parent view executes multiple steps but sometimes the animation subview is not visible in the first step. However when it does become visible the animation has already taken on the appearance of the end state. You can see this behavior in the following example.
Parent View
struct ContentView: View {
#State var firstTime = true
#State var breath = false
var body: some View {
VStack {
// This subview is not displayed until after the first time
if !firstTime {
SecondView(breath: $breath)
}
Spacer()
// A button click simulates the steps in my App by toggling the #Binding var
Button("Breath") {
withAnimation {
self.breath.toggle()
self.firstTime = false
}
}
// This vies shows what happens when the subview is being displayed with an intial state of false for the #Binding var
Spacer()
SecondView(breath: $breath)
}
}
}
Here is the subview containing the animation and using a #Binding var to control the animation appearance.
struct SecondView: View {
#Binding var breath: Bool
var body: some View {
Image(systemName: "flame")
.resizable()
.rotationEffect(.degrees(breath ? 360 : 0), anchor: .center)
.scaleEffect(breath ? 1 : 0.2)
.opacity(breath ? 1 : 0.75)
.animation(.easeInOut(duration: 2))
.foregroundColor(Color.red)
}
}
When you execute this the first time thru the top subview is not being displayed and when you click the button the lower subview executes the expected animation and then toggles the firstTime var so that the top subview becomes visible. Notice that the animation is fully expanded and if I was to then have another step (click) with the same value of true for the #Binding property the view would not change at all. This is the issue I am wrestling with. I would like to keep the subview from being at the end state if the first step is one that has toggled the Bool value even if the subview was not being displayed. In other words I would like to only initialize the subview when it is actually being displayed with a value of true so that the animation will always start out small.
This is why I was hoping to have the subview initialized the Binding var to false until it actually gets invoked for the first time (or to reset its state to the shrunk version of the animation) whichever is more feasible.
It looks like you may want to initialise _breath with the provided parameter:
struct ContentView: View {
#Binding var breath: Bool
init(breath: Binding<Bool>) {
_breath = breath
}
}
However, if you want to use a constant value (in your example false) you can do:
struct ContentView: View {
#Binding var breath: Bool
init(breath: Binding<Bool>) {
_breath = .constant(false)
}
}
But then, why do you need the breath: Binding<Bool> parameter?
EDIT
Here is an example how to control an animation of a child view using a #Binding variable:
struct ContentView: View {
#State var breath = false
var body: some View {
VStack {
Button("Breath") {
withAnimation {
self.breath.toggle()
}
}
SecondView(breath: $breath)
}
}
}
struct SecondView: View {
#Binding var breath: Bool
var body: some View {
Image(systemName: "flame")
.imageScale(.large)
.rotationEffect(.degrees(breath ? 360 : 0), anchor: .center)
.scaleEffect(breath ? 1 : 0.2)
.opacity(breath ? 1 : 0.75)
.animation(.easeInOut(duration: 2))
}
}