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.
Related
I want to replicate the layout of the time label in the iOS Camera app in a SwiftUI view.
My test code is this:
class TimeTestModel:ObservableObject {
#Published var seconds:Int = 0
var timer:Timer?
init() {
timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(TimeTestModel.tick), userInfo: nil, repeats: true)
}
#objc func tick() {
seconds += 1
}
}
struct TestView: View {
#StateObject var timeModel = TimeTestModel()
var body: some View {
VStack() {
HStack {
Text("00:0\(timeModel.seconds)")
}
.border(.red)
}
}
}
It creates this:
What I want is this:
Using my code you can see how the whole text moves left to right as new text is rendered (I have only done from 0 to 9 for the sake of the example, I know I need more String work to go beyond that...).
In the iOS camera app version you can see the positioning of the whole label does not change as the time changes.
I am not sure how to achieve this in SwiftUI.
How can I change my code to allow digits to update without causing horizontal shift?
Like Yrb mentioned in his comments, this can be solved using a non proportional Font. So I achieved the wanted result by making this change:
VStack {
HStack {
Text(timeModel.timeString)
.font(Font.system(size: 12, weight: .medium).monospacedDigit()). <<-- HERE!
}
.border(.red)
}
Now the Text has zero horizontal shift.
I am creating a reusable gallery view for an app and am having difficulties when any picture is tapped it suppose to become full screen but only the first picture in the array is shown every time no matter the picture tapped. Below is my code, thanks.
import SwiftUI
struct ReusableGalleryView: View {
let greenappData: GreenAppNews
let gridLayout: [GridItem] = Array(repeating: GridItem(.flexible()), count: 3)
#State private var fullscreen = false
#State private var isPresented = false
var body: some View {
VStack{
ScrollView{
LazyVGrid(columns: gridLayout, spacing: 3) {
ForEach(greenappData.GreenAppGallery, id: \.self) { item in
Image(item)
.resizable()
.frame(width: UIScreen.main.bounds.width/3, height: 150)
.onTapGesture {
self.isPresented.toggle()
print(" tapping number")
}
.fullScreenCover(isPresented: $isPresented) {
FullScreenModalView( imageFiller: item)
}
.background(Color.gray.opacity(0.5))
}
}
.padding(.horizontal)
}
}
}
}
This is an example of the json data:
{
"id" : "1",
"GreenAppGallery" : [
"Picture-1",
"Picture-2",
"Picture-3",
"Picture-4",
"Picture-5",
"Picture-6",
"Picture-7",
"Picture-8",
"Picture-9",
"Picture-10"
]
},
fullScreenCover, like sheet tends to create this type of behavior in iOS 14 when using isPresented:.
To fix it, you can change to the fullScreenCover(item: ) form.
Not having all of your code, I'm not able to give you an exact version of what it'll look like, but the gist is this:
Remove your isPresented variable
Replace it with a presentedItem variable that will be an optional. Probably a datatype that is in your gallery. Note that it has to conform to Identifiable (meaning it has to have an id property).
Instead of toggling isPresented, set presentedItem to item
Use fullScreenCover(item: ) { presentedItem in FullScreenModalView( imageFiller: presentedItem) } and pass it your presentedItem variable
Move the fullScreenCover so that it's attached to the ForEach loop rather than the Image
Using this system, you should see it respond to the correct item.
Here's another one of my answers that covers this with sheet: #State var not updated as expected in LazyVGrid
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)
}
}
}
}
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)
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))
}
}