SwiftUI Animation Doesn't Always Run - swift

I'm working on some code to teach myself SwiftUI, and my current project is a dice roller app.
In my view, I present some images using SFSymbols to represent the value of each die after the roll. I use two animations (.rotation3Deffect and .rotationEffect) on each image to give a visual roll of the dice.
I have found that the animations will only run if the individual die value (or in case of the code, if the Image(systemName: "\(diceValue).square.fill")) changes from one roll to the next. In other words, if I roll three dice, and they land on 1-4-6, then I roll again and they land on 1-4-7, the 1 and 4 images will perform the animation, but the image changing from 6 to 7 does not animate - it just updates to the new image. Any idea why the animation only runs on the images that did not change from the previous dice roll?
import SwiftUI
struct Dice {
var sides: Int
var values: [Int] {
var calculatedValues = [Int]()
for v in 1...sides {
calculatedValues.append(v)
}
return calculatedValues
}
func rollDice() -> Int {
let index = Int.random(in: 0..<values.count)
return values[index]
}
}
struct Roll: Hashable, Identifiable {
let id = UUID()
var diceNumber: Int = 0
var diceValue: Int = 0
}
struct DiceThrow: Hashable {
var rolls = [Roll]()
var totalScore: Int {
var score = 0
for roll in rolls {
score += roll.diceValue
}
return score
}
}
class ThrowStore: ObservableObject, Hashable {
#Published var diceThrows = [DiceThrow]()
static func ==(lhs: ThrowStore, rhs: ThrowStore) -> Bool {
return lhs.diceThrows == rhs.diceThrows
}
func hash(into hasher: inout Hasher) {
hasher.combine(diceThrows)
}
}
class Settings: ObservableObject {
#Published var dicePerRoll: Int = 1
#Published var sidesPerDice: Int = 4
init(dicePerRoll: Int, sidesPerDice: Int) {
self.dicePerRoll = dicePerRoll
self.sidesPerDice = sidesPerDice
}
}
struct ContentView: View {
var throwStore = ThrowStore()
let settings = Settings(dicePerRoll: 3, sidesPerDice: 6)
#State private var roll = Roll()
#State private var diceThrow = DiceThrow()
#State private var isRotated = false
#State private var rotationAngle: Double = 0
var animation: Animation {
Animation.easeOut(duration: 3)
}
var body: some View {
VStack {
if self.throwStore.diceThrows.count != 0 {
HStack {
Text("For dice roll #\(self.throwStore.diceThrows.count)...")
.fontWeight(.bold)
.font(.largeTitle)
.padding(.leading)
Spacer()
}
}
List {
HStack(alignment: .center) {
Spacer()
ForEach(diceThrow.rolls, id: \.self) { printedRoll in
Text("\(printedRoll.diceValue)")
.font(.title)
.foregroundColor(.white)
.frame(width: 50, height: 50)
.background(RoundedRectangle(cornerRadius: 4))
.rotation3DEffect(Angle.degrees(isRotated ? 3600 : 0), axis: (x: 1, y: 0, z: 0))
.rotationEffect(Angle.degrees(isRotated ? 3600 : 0))
.animation(animation)
}
Spacer()
}
if self.throwStore.diceThrows.count != 0 {
HStack {
Text("Total for this roll:")
Spacer()
Text("\(self.diceThrow.totalScore)")
.padding(.leading)
.padding(.trailing)
.background(Capsule().stroke())
}
}
}
Spacer()
Button("Roll the dice") {
self.diceThrow.rolls = [Roll]()
self.isRotated.toggle()
for diceNumber in 1...settings.dicePerRoll {
let currentDice = Dice(sides: settings.sidesPerDice)
roll.diceNumber = diceNumber
roll.diceValue = currentDice.rollDice()
self.diceThrow.rolls.append(roll)
}
self.throwStore.diceThrows.append(self.diceThrow)
}
.frame(width: 200)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.clipShape(Capsule())
.padding(.bottom)
}
}
func showDiceRoll(_ sides: Int) {
}
}

In your scenario the data identifiers in ForEach should remain the same, otherwise corresponding views are replaced and animation does not work.
The fix for this is simple - use diceNumber as ID:
ForEach(diceThrow.rolls, id: \.diceNumber) { printedRoll in
Text("\(printedRoll.diceValue)")
Tested with Xcode 12.1 / iOS 14.1

Related

Conditional view modifier sometimes doesn't want to update

As I am working on a study app, I'm tring to build a set of cards, that a user can swipe each individual card, in this case a view, in a foreach loop and when flipped through all of them, it resets the cards to normal stack. The program works but sometimes the stack of cards doesn't reset. Each individual card updates a variable in a viewModel which my conditional view modifier looks at, to reset the stack of cards using offset and when condition is satisfied, the card view updates, while using ".onChange" to look for the change in the viewModel to then update the variable back to original state.
I've printed each variable at each step of the way and every variable updates and I can only assume that the way I'm updating my view, using conditional view modifier, may not be the correct way to go about. Any suggestions will be appreciated.
Here is my code:
The view that houses the card views with the conditional view modifier
extension View {
#ViewBuilder func `resetCards`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition == true {
transform(self).offset(x: 0, y: 0)
} else {
self
}
}
}
struct StudyListView: View {
#ObservedObject var currentStudySet: HomeViewModel
#ObservedObject var studyCards: StudyListViewModel = StudyListViewModel()
#State var studyItem: StudyModel
#State var index: Int
var body: some View {
ForEach(currentStudySet.allSets[index].studyItem.reversed()) { item in
StudyCardItemView(currentCard: studyCards, card: item, count: currentStudySet.allSets[index].studyItem.count)
.resetCards(studyCards.isDone) { view in
view
}
.onChange(of: studyCards.isDone, perform: { _ in
studyCards.isDone = false
})
}
}
}
StudyCardItemView
struct StudyCardItemView: View {
#StateObject var currentCard: StudyListViewModel
#State var card: StudyItemModel
#State var count: Int
#State var offset = CGSize.zero
var body: some View {
VStack{
VStack{
ZStack(alignment: .center){
Text("\(card.itemTitle)")
}
}
}
.frame(width: 350, height: 200)
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 5)
.padding(5)
.rotationEffect(.degrees(Double(offset.width / 5)))
.offset(x: offset.width * 5, y: 0)
.gesture(
DragGesture()
.onChanged { gesture in
offset = gesture.translation
}
.onEnded{ _ in
if abs(offset.width) > 100 {
currentCard.cardsSortedThrough += 1
if (currentCard.cardsSortedThrough == count) {
currentCard.isDone = true
currentCard.cardsSortedThrough = 0
}
} else {
offset = .zero
}
}
)
}
}
HomeViewModel
class HomeViewModel: ObservableObject {
#Published var studySet: StudyModel = StudyModel()
#Published var allSets: [StudyModel] = [StudyModel()]
}
I initialize allSets with one StudyModel() to see it in the preview
StudyListViewModel
class StudyListViewModel: ObservableObject {
#Published var cardsSortedThrough: Int = 0
#Published var isDone: Bool = false
}
StudyModel
import SwiftUI
struct StudyModel: Hashable{
var title: String = ""
var days = ["One day", "Two days", "Three days", "Four days", "Five days", "Six days", "Seven days"]
var studyGoals = "One day"
var studyItem: [StudyItemModel] = []
}
Lastly, StudyItemModel
struct StudyItemModel: Hashable, Identifiable{
let id = UUID()
var itemTitle: String = ""
var itemDescription: String = ""
}
Once again, any help would be appreciated, thanks in advance!
I just found a fix and I put .onChange at the end for StudyCardItemView. Basically, the onChange helps the view scan for a change in currentCard.isDone variable every time it was called in the foreach loop and updates offset individuality. This made my conditional view modifier obsolete and just use the onChange to check for the condition.
I still used onChange outside the view with the foreach loop, just to set currentCard.isDone variable false because the variable will be set after all array elements are iterator through.
The updated code:
StudyCardItemView
struct StudyCardItemView: View {
#StateObject var currentCard: StudyListViewModel
#State var card: StudyItemModel
#State var count: Int
#State var offset = CGSize.zero
var body: some View {
VStack{
VStack{
ZStack(alignment: .center){
Text("\(card.itemTitle)")
}
}
}
.frame(width: 350, height: 200)
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 5)
.padding(5)
.rotationEffect(.degrees(Double(offset.width / 5)))
.offset(x: offset.width * 5, y: 0)
.gesture(
DragGesture()
.onChanged { gesture in
offset = gesture.translation
}
.onEnded{ _ in
if abs(offset.width) > 100 {
currentCard.cardsSortedThrough += 1
if (currentCard.cardsSortedThrough == count) {
currentCard.isDone = true
currentCard.cardsSortedThrough = 0
}
} else {
offset = .zero
}
}
)
.onChange(of: currentCard.isDone, perform: {_ in
offset = .zero
})
}
}
StudyListView
struct StudyListView: View {
#ObservedObject var currentStudySet: HomeViewModel
#ObservedObject var studyCards: StudyListViewModel = StudyListViewModel()
#State var studyItem: StudyModel
#State var index: Int
var body: some View {
ForEach(currentStudySet.allSets[index].studyItem.reversed()) { item in
StudyCardItemView(currentCard: studyCards, card: item, count:
currentStudySet.allSets[index].studyItem.count)
.onChange(of: studyCards.isDone, perform: { _ in
studyCards.isDone = false
})
}
}
}
Hope this helps anyone in the future!

How to dynamically change GridItems in LazyVGrid with MagnificationGesture [Zoom In, Out] in SwiftUI?

The idea is to recreate the same photo layout behaviour like in Apple Photo Library when I can zoom in and out with 1, 3 or 5 photos in a row. I'm stack in a half way. For that I use a MagnificationGesture() and based on gesture value I update number of GridItems() in LazyVGrid().
Please let me know how to achieve it. Thanks a lot ๐Ÿ™
Here's code:
import SwiftUI
struct ContentView: View {
let colors: [Color] = [.red, .purple, .yellow, .green, .blue, .mint, .orange]
#State private var colums = Array(repeating: GridItem(), count: 1)
// #GestureState var magnifyBy: CGFloat = 1.0
#State var magnifyBy: CGFloat = 1.0
#State var lastMagnifyBy: CGFloat = 1.0
let minMagnifyBy = 1.0
let maxMagnifyBy = 5.0
var magnification: some Gesture {
MagnificationGesture()
// .updating($magnifyBy) { (currentState, pastState, trans) in
// pastState = currentState.magnitude
// }
.onChanged { state in
adjustMagnification(from: state)
print("Current State \(state)")
}
.onEnded { state in
adjustMagnification(from: state)
// withAnimation(.spring()) {
// validateMagnificationLimits()
// }
lastMagnifyBy = 1.0
}
}
var body: some View {
NavigationView {
ScrollView {
LazyVGrid(columns: colums) {
ForEach(1..<101) { number in
colors[number % colors.count]
.overlay(Text("\(number)").font(.title2.bold()).foregroundColor(.white))
.frame(height: 100)
}
}
.scaleEffect(magnifyBy)
.gesture(magnification)
.navigationTitle("๐Ÿงจ Grid")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
withAnimation(.spring(response: 0.8)) {
colums = Array(repeating: .init(), count: colums.count == 5 ? 1 : colums.count % 5 + 2)
}
} label: {
Image(systemName: "square.grid.3x3")
.font(.title2)
.foregroundColor(.primary)
}
}
}
}
}
}
private func adjustMagnification(from state: MagnificationGesture.Value) {
let stepCount = Int(min(max(1, state), 5))
// let delta = state / lastMagnifyBy
// magnifyBy *= delta
withAnimation(.linear) {
colums = Array(repeating: GridItem(), count: stepCount)
}
lastMagnifyBy = state
}
private func getMinMagnificationAllowed() -> CGFloat {
max(magnifyBy, minMagnifyBy)
}
private func getMaxMagnificationAllowed() -> CGFloat {
min(magnifyBy, maxMagnifyBy)
}
private func validateMagnificationLimits() {
magnifyBy = getMinMagnificationAllowed()
magnifyBy = getMaxMagnificationAllowed()
}
}
Here you go. This uses a TrackableScrollView (git link in the code).
I implemented an array of possible zoomStages (cols per row), to make switching between them easier.
Next to dos would be scrolling back to the magnification center, so the same item stays in focus. And maybe an opacity transition in stead of rearranging the Grid. Have fun ;)
import SwiftUI
// https://github.com/maxnatchanon/trackable-scroll-view.git
import SwiftUITrackableScrollView
struct ContentView: View {
let colors: [Color] = [.red, .purple, .yellow, .green, .blue, .mint, .orange]
let zoomStages = [1, 3, 5, 9, 15]
#State private var zoomStageIndex = 0
var colums: [GridItem] { Array(repeating: GridItem(spacing: 0), count: zoomStages[zoomStageIndex]) }
#State var magnifyBy: CGFloat = 1.0
#State private var scrollViewOffset = CGFloat.zero // SwiftUITrackableScrollView: Content offset available to use
var body: some View {
NavigationView {
TrackableScrollView(.vertical, showIndicators: false, contentOffset: $scrollViewOffset) {
LazyVGrid(columns: colums, spacing: 0) {
ForEach(0..<500) { number in
colors[number % colors.count]
.overlay(
Text("\(number)").font(.title2.bold()).foregroundColor(.white)
.minimumScaleFactor(0.1)
)
.aspectRatio(1, contentMode: .fit) // always squares
.id(number)
}
}
.scaleEffect(magnifyBy, anchor: .top)
// offset to correct magnify "center" point
.offset(x: 0, y: (scrollViewOffset + UIScreen.main.bounds.midY) * (1 - magnifyBy) )
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
withAnimation(.spring(response: 0.8)) {
if zoomStageIndex < zoomStages.count-1 {
zoomStageIndex += 1
} else {
zoomStageIndex = 0
}
}
} label: {
Image(systemName: "square.grid.3x3")
.font(.title2)
.foregroundColor(.primary)
}
}
}
.gesture(magnification)
}
.ignoresSafeArea()
}
}
var magnification: some Gesture {
MagnificationGesture()
.onChanged { state in
magnifyBy = state
}
.onEnded { state in
// find predefined zoom(index) that is closest to actual pinch zoom value
let newZoom = Double(zoomStages[zoomStageIndex]) * 1 / state
let newZoomIndex = findClosestZoomIndex(value: newZoom)
// print("***", zoomStages[zoomStageIndex], state, newZoom, newZoomIndex)
withAnimation(.spring(response: 0.8)) {
magnifyBy = 1 // reset scaleEffect
zoomStageIndex = newZoomIndex // set new zoom level
}
}
}
func findClosestZoomIndex(value: Double) -> Int {
let distanceArray = zoomStages.map { abs(Double($0) - value) } // absolute difference between zoom stages and actual pinch zoom
// print("dist:", distanceArray)
return distanceArray.indices.min(by: {distanceArray[$0] < distanceArray[$1]}) ?? 0 // return index of element that is "closest"
}
}

SwiftUI - Fatal error: index out of range on deleting element from an array

I have an array on appstorage. I'm displaying its elements with foreach method. It has swipe to delete on each element of the array. But when i delete one, app is crashing. Here is my first view;
struct View1: View {
#Binding var storedElements: [myElements]
var body: some View{
GeometryReader {
geometry in
VStack{
ForEach(storedElements.indices, id: \.self){i in
View2(storedElements: $storedElements[i], pic: $storedWElements[i].pic, allElements: $storedElements, index: i)
}
}.frame(width: geometry.size.width, height: geometry.size.height / 2, alignment: .top).padding(.top, 25)
}
}
}
And View 2;
struct View2: View {
#Binding var storedElements: myElements
#Binding var pic: String
#Binding var allElements: [myElements]
var index: Int
#State var offset: CGFloat = 0.0
#State var isSwiped: Bool = false
#AppStorage("pics", store: UserDefaults(suiteName: "group.com.some.id"))
var arrayData: Data = Data()
var body : some View {
ZStack{
Color.red
HStack{
Spacer()
Button(action: {
withAnimation(.easeIn){
delete()}}) {
Image(systemName: "trash").font(.title).foregroundColor(.white).frame(width: 90, height: 50)
}
}
View3(storedElements: $storedElements).background(Color.red).contentShape(Rectangle()).offset(x: self.offset).gesture(DragGesture().onChanged(onChanged(value:)).onEnded(onEnd(value:)))
}.frame(width: 300, height: 175).cornerRadius(30)
}
}
func onChanged(value: DragGesture.Value) {
if value.translation.width < 0 {
if self.isSwiped {
self.offset = value.translation.width - 90
}else {
self.offset = value.translation.width
}
}
}
func onEnd(value: DragGesture.Value) {
withAnimation(.easeOut) {
if value.translation.width < 0 {
if -value.translation.width > UIScreen.main.bounds.width / 2 {
self.offset = -100
delete()
}else if -self.offset > 50 {
self.isSwiped = true
self.offset = -90
}else {
self.isSwiped = false
self.offset = 0
}
}else {
self.isSwiped = false
self.offset = 0
}
}
}
func delete() {
//self.allElements.remove(at: self.index)
if let index = self.allElements.firstIndex(of: storedElements) {
self.allElements.remove(at: index)
}
}
}
OnChange and onEnd functions are for swiping. I think its the foreach method that is causing crash. Also tried the commented line on delete function but no help.
And I know its a long code for a question. I'm trying for days and tried every answer here but none of them solved my problem here.
In your myElements class/struct, ensure it has a unique property. If not add one and upon init set a unique ID
public class myElements {
var uuid: String
init() {
self.uuid = NSUUID().uuidString
}
}
Then when deleting the element, instead of
if let index = self.allElements.firstIndex(of: storedElements) {
self.allElements.remove(at: index)
}
Use
self.allElements.removeAll(where: { a in a.uuid == storedElements.uuid })
This is array bound safe as it does not use an index

How to bring a view on top of VStack containing ZStacks

I need a view grid where each item resizes depending on the number of items in the grid and I need to expand each item of the grid when tapped.
I eventually managed to layout out the items as required (see Fig 1) and to expand them.
Unfortunately I am not able to properly bring the expanded item in front of all other views (see Fig 2 and Fig 3) using zIndex.
I also tried to embed both VStack and HStack into a ZStack but nothing changes.
How can I bring the expanded item on top?
Below is my code.
struct ContentViewNew: View {
private let columns: Int = 6
private let rows: Int = 4
#ObservedObject var viewModel: ViewModel
var cancellables = Set<AnyCancellable>()
init() {
viewModel = ViewModel(rows: rows, columns: columns)
viewModel.objectWillChange.sink { _ in
print("viewModel Changed")
}.store(in: &cancellables)
}
var body: some View {
GeometryReader { geometryProxy in
let hSpacing: CGFloat = 7
let vSpacing: CGFloat = 7
let hSize = (geometryProxy.size.width - hSpacing * CGFloat(columns + 1)) / CGFloat(columns)
let vSize = (geometryProxy.size.height - vSpacing * CGFloat(rows + 1)) / CGFloat(rows)
let size = min(hSize, vSize)
VStack {
ForEach(0 ..< viewModel.rows, id: \.self) { row in
Spacer()
HStack {
Spacer()
ForEach(0 ..< viewModel.columns, id: \.self) { column in
GeometryReader { widgetProxy in
ItemWiew(info: viewModel.getItem(row: row, column: column), size: size, zoomedSize: 0.80 * geometryProxy.size.width)
.offset(x: viewModel.getItem(row: row, column: column).zoomed ? (geometryProxy.size.width / 2.0 - (widgetProxy.frame(in: .global).origin.x + widgetProxy.size.width / 2.0)) : 0,
y: viewModel.getItem(row: row, column: column).zoomed ? geometryProxy.size.height / 2.0 - (widgetProxy.frame(in: .global).origin.y + widgetProxy.size.height / 2.0) : 0)
.onTapGesture {
viewModel.zoom(row: row, column: column)
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
.zIndex(viewModel.getItem(row: row, column: column).zoomed ? 10000 : 0)
.background(Color.gray)
}
Spacer()
}
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
.background(Color.blue)
Spacer()
}
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
.background(Color.yellow)
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
.background(Color.green)
}
}
struct ItemWiew: View {
#ObservedObject var info: ItemInfo
var size: CGFloat
init(info: ItemInfo, size: CGFloat, zoomedSize: CGFloat) {
self.info = info
self.size = size
if self.info.size == 0 {
self.info.size = size
self.info.zoomedSize = zoomedSize
}
}
var body: some View {
VStack {
Print("Drawing Widget with size \(self.info.size)")
Image(systemName: info.symbol)
.font(.system(size: 30))
.frame(width: info.size, height: info.size)
.background(info.color)
.cornerRadius(10)
}
}
}
class ItemInfo: ObservableObject, Identifiable {
var symbol: String
var color: Color
var zoomed = false
#Published var size: CGFloat
#Published var originalSize: CGFloat
#Published var zoomedSize: CGFloat
init(symbol: String, color: Color) {
self.symbol = symbol
self.color = color
size = 0.0
originalSize = 0.0
zoomedSize = 0.0
}
func toggleZoom() {
if zoomed {
size = originalSize
color = .red
} else {
size = zoomedSize
color = .white
}
zoomed.toggle()
}
}
class ViewModel: ObservableObject {
private var symbols = ["keyboard", "hifispeaker.fill", "printer.fill", "tv.fill", "desktopcomputer", "headphones", "tv.music.note", "mic", "plus.bubble", "video"]
private var colors: [Color] = [.yellow, .purple, .green]
#Published var listData = [ItemInfo]()
var rows = 0
var columns = 0
init(rows: Int, columns: Int) {
self.rows = rows
self.columns = columns
for _ in 0 ..< rows {
for j in 0 ..< columns {
listData.append(ItemInfo(symbol: symbols[j % symbols.count], color: colors[j % colors.count]))
}
}
}
func getItem(row: Int, column: Int) -> ItemInfo {
return listData[columns * row + column]
}
func zoom(row: Int, column: Int) {
listData[columns * row + column].toggleZoom()
objectWillChange.send()
}
}
There is a lot of code you posted. I tried to simplify it a bit. Mostly you overused size/zoomedSize/originalSize properties.
First you can make ItemInfo a struct and remove all size related properties:
struct ItemInfo {
var symbol: String
var color: Color
init(symbol: String, color: Color) {
self.symbol = symbol
self.color = color
}
}
Then simplify your ViewModel again by removing all size related properties:
class ViewModel: ObservableObject {
private var symbols = ["keyboard", "hifispeaker.fill", "printer.fill", "tv.fill", "desktopcomputer", "headphones", "tv.music.note", "mic", "plus.bubble", "video"]
private var colors: [Color] = [.yellow, .purple, .green]
#Published var listData = [ItemInfo]()
let rows: Int
let columns: Int
init(rows: Int, columns: Int) {
self.rows = rows
self.columns = columns
for _ in 0 ..< rows {
for j in 0 ..< columns {
listData.append(ItemInfo(symbol: symbols[j % symbols.count], color: colors[j % colors.count]))
}
}
}
func getItem(row: Int, column: Int) -> ItemInfo {
return listData[columns * row + column]
}
}
Then update your ItemView (again remove all size related properties from outer views and use a GeometryReader directly):
struct ItemWiew: View {
let itemInfo: ItemInfo
var body: some View {
GeometryReader { proxy in
self.imageView(proxy: proxy)
}
}
// extracted to another function as you can't use `let` inside a `GeometryReader` closure
func imageView(proxy: GeometryProxy) -> some View {
let sideLength = min(proxy.size.width, proxy.size.height) // to make it fill all the space but remain a square
return Image(systemName: itemInfo.symbol)
.font(.system(size: 30))
.frame(maxWidth: sideLength, maxHeight: sideLength)
.background(itemInfo.color)
.cornerRadius(10)
}
}
Now you can update the ContentView:
struct ContentView: View {
private let columns: Int = 6
private let rows: Int = 4
#ObservedObject var viewModel: ViewModel
// zoomed item (nil if no item is zoomed)
#State var zoomedItem: ItemInfo?
init() {
viewModel = ViewModel(rows: rows, columns: columns)
}
var body: some View {
ZStack {
gridView
zoomedItemView
}
}
var gridView: some View {
let spacing: CGFloat = 7
return VStack(spacing: spacing) {
ForEach(0 ..< viewModel.rows, id: \.self) { rowIndex in
self.rowView(rowIndex: rowIndex)
}
}
.padding(.all, spacing)
}
func rowView(rowIndex: Int) -> some View {
let spacing: CGFloat = 7
return HStack(spacing: spacing) {
ForEach(0 ..< viewModel.columns, id: \.self) { columnIndex in
ItemWiew(itemInfo: self.viewModel.getItem(row: rowIndex, column: columnIndex))
.onTapGesture {
// set zoomed item on tap gesture
self.zoomedItem = self.viewModel.getItem(row: rowIndex, column: columnIndex)
}
}
}
}
}
Lastly in the zoomedItemView I reused the ItemView but you can create some other view just for the zoomed item:
extension ContentView {
var zoomedItemView: some View {
Group {
if zoomedItem != nil {
ItemWiew(itemInfo: zoomedItem!)
.onTapGesture {
self.zoomedItem = nil
}
}
}
.padding()
}
}
Note: for simplicity I made ItemInfo a struct. This is recommended if you don't plan to modify it inside the zoomedView and apply changes to the grid. But if for some reason you need it to be a class and an ObservableObject you can easily restore your original declaration:
class ItemInfo: ObservableObject, Identifiable { ... }
No item selected:
With a zoomed item:

Array of structs not updating in a view

I have an array of observed object, that contains an array of structs, that contain data. I would like to show it onscreen. This data is originally shown onscreen but the changes are not pushed whenever I make a change to an item in the array. I am even changing the property within the struct. I have tried it in my manager class aswell. I've done a bit of digging, changing my approach several times, but I can't get this working. I am very new to swiftui/swift as-well-as stack-overflow.
Full code:
struct GameView: View {
#State var value: CGFloat = 0
#ObservedObject var circles = GameCircles()
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
Color.primary
.frame(width: 450, height: 800)
self.circles.getViews()
.onReceive(timer) { _ in
self.circles.tick()
}
}
.edgesIgnoringSafeArea(.all)
.gesture(DragGesture(minimumDistance: 20)
.onChanged { gest in
self.value = gest.location.x
})
}
}
class GameCircles: ObservableObject {
#Published var circles: [GameCircle] = []
func getViews() -> some View {
ForEach(circles, id: \.id) { circle in
circle.makeView()
}
}
func tick() {
for circle in circles {
circle.tick()
print(circle.y)
}
circles.append(GameCircle(x: Int.random(in: -200...200), y: -200))
}
}
struct GameCircle: Identifiable {
#State var x: Int
#State var y: Int
let color = Color.random()
var id = UUID()
func tick() {
self.y += 1
}
func makeView() -> some View {
return ZStack {
Circle()
.frame(width: 40, height: 40)
.foregroundColor(color)
.animation(.default)
Text("\(Int(y))")
.foregroundColor(.black)
}
.offset(x: CGFloat(self.x), y: CGFloat(self.y))
}
}
I played with this code around, trying to solve your problem. And it was surprise for me, that state var in View didn't change in ForEach loop (you'll see it at the screenshot). Ok, I rewrite your code, now circles going down:
// MARK: models
class GameCircles: ObservableObject {
#Published var circles: [CircleModel] = []
func addNewCircle() {
circles.append(CircleModel(x: CGFloat.random(in: -200...200), y: -200))
}
}
struct CircleModel: Identifiable, Equatable {
let id = UUID()
var x: CGFloat
var y: CGFloat
mutating func pushDown() {
self.y += 5
}
}
// MARK: views
struct GameView: View {
#State var gameSeconds = 0
#ObservedObject var game = GameCircles()
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
Color.primary
.frame(width: 450, height: 800)
ForEach(self.game.circles) { circle in
CircleView(y: circle.y)
.offset(x: circle.x, y: circle.y)
.onReceive(self.timer) { _ in
let circleIndex = self.game.circles.firstIndex(of: circle)!
self.game.circles[circleIndex].pushDown()
}
}
.onReceive(self.timer) { _ in
self.game.addNewCircle()
}
}
.edgesIgnoringSafeArea(.all)
}
}
struct CircleView: View {
#State var y: CGFloat
var body: some View {
ZStack {
Circle()
.frame(width: 40, height: 40)
.foregroundColor(.red)
.animation(.default)
Text("\(Int(y))")
.foregroundColor(.black)
}
}
}
struct GameView_Previews: PreviewProvider {
static var previews: some View {
GameView()
}
}
as you can see #State var y doesn't change and I wonder why? Nevertheless, I hope it could help you.
P.S. I rewrote the code for a few times, so that is not the only solution and you may use tick() func as in the question and the code will be more clear
class GameCircles: ObservableObject {
#Published var circles: [CircleModel] = []
func tick() {
for index in circles.indices {
circles[index].pushDown()
}
circles.append(CircleModel(x: CGFloat.random(in: -200...200), y: -200))
}
}
struct GameView: View {
#State var gameSeconds = 0
#ObservedObject var game = GameCircles()
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
Color.primary
.frame(width: 450, height: 800)
ForEach(self.game.circles) { circle in
CircleView(y: circle.y)
.offset(x: circle.x, y: circle.y)
}
.onReceive(self.timer) { _ in
self.game.tick()
}
}
.edgesIgnoringSafeArea(.all)
}
}