BLACK FRIDAY SALE: Save big on all my Swift books and bundles! >>

LazyVGrid not updating?

Forums > SwiftUI

Hi all,

I'm very new to learning swift and programming in general and I've got a bit stuck with why my LazyVGrid is not automatically updating.

I have created a grid of hexagons and the image for each hexagon is controlled by an @Published dictionary in a seperate 'gameController' class. I want to select a hexagon and then update the image value to represent placing a building on the tile and the map refreshes

Sorry if this is way too much code being posted but I wanted to try and include everything relevant!

GameController class - there are other things in this and this is passed around all my other views and works fine

class GameController: ObservableObject{
...
@Published var mapStatus: [Int : String]
    @Published var showMapPopUp: Bool
    @Published var selectedTile : Int
    }

    init() {
    //each default tile image is set in the initialiser
        mapStatus = [0: "dirt_02", 1: "dirt_02",........... 103: "dirt_02", 104: "dirt_02"]
        showMapPopUp = false
        selectedTile = 0
        }

Build the hexagon shape. Change the build menu show flag and records the selected hexagon index into the game controller (numbers on tiles are just for testing purposes)

struct Hexagon: View {

    @State var text : String
    let cols : Int
    let imgsize : CGSize
    var hexagonWidth : CGFloat
    var tapIndex : Int

    @ObservedObject var gameController : GameController

    var body: some View {
        ZStack{

         Image(text)
            .resizable()
            .scaledToFit()
            .frame(width: imgsize.width, height: imgsize.height)
            .clipShape(PolygonShape(sides: 6).rotation(Angle.degrees(90)))
            .offset(x: isEvenRow(tapIndex) ? 0 : hexagonWidth / 2)
            .onTapGesture {
                print(tapIndex)
                gameController.selectedTile = tapIndex
                gameController.showMapPopUp = true

            }

            Text("\(tapIndex)")
                .offset(x: isEvenRow(tapIndex) ? 0 : hexagonWidth / 2)

        }
    }

    func isEvenRow(_ idx: Int) -> Bool {
        (idx / cols) % 2 == 0
    }

}

Map View which builds the hexagons into a honeycomb shape and shows the build menu popup is the flag is true

struct MapView: View {
@ObservedObject var gameController : GameController
    let spacing:CGFloat = -5
    let imgsize = CGSize(width: 50, height: 50)
    var hexagonWidth: CGFloat { (imgsize.width / 2) * cos(.pi / 6) * 2}
    let cols = 15
    @State private var showPopUp = false

    var body: some View {
        let gridItemsInit = Array(repeating: GridItem(.fixed(imgsize.width), spacing: spacing), count: cols)

        ZStack {
            VStack{

            ScrollView([.horizontal, .vertical], showsIndicators: false) {
                LazyVGrid(columns: gridItemsInit, spacing: spacing) {
                    ForEach(gameController.mapStatus.sorted(by: <), id: \.key) { key, value in
                        Hexagon(text: gameController.mapStatus[key] ?? "", cols: cols, imgsize: imgsize, hexagonWidth: hexagonWidth, tapIndex: key, gameController : gameController)

                    }
                    .frame(width: hexagonWidth, height: imgsize.height * 0.85)
                }

            }
            .navigationBarHidden(true)
            }
            .background(.gray)

            if gameController.showMapPopUp == true {
                MapPopup(gameController: gameController)
            }

        }

    }   

}

Build menu pop-up which selects what you want the tile image to change to and updates the dictionary value

struct MapPopup: View {
    @ObservedObject var gameController: GameController

    var body: some View {
        VStack{
            Text("Choose your action")

            HStack{
                Image(gameController.food.image)
                    .resizable()
                    .scaledToFit()
                Spacer()
                Text("Build a farm")
                    Spacer()
            }
            .frame(width: 200, height: 60)
            .border(.green, width: 3)
            .onTapGesture {
                gameController.mapStatus[gameController.selectedTile] = "medieval_windmill"

                gameController.showMapPopUp = false

                print(gameController.mapStatus[gameController.selectedTile])

            }

            HStack{
                Image(gameController.wood.image)
                    .resizable()
                    .scaledToFit()
                Spacer()
                Text("Build a lumber camp")
                    .multilineTextAlignment(.center)
                    Spacer()
            }
            .frame(width: 200, height: 60)
            .border(.brown, width: 3)
            .onTapGesture {
                gameController.showMapPopUp = false
                gameController.mapStatus[gameController.selectedTile] = "medieval_lumber"

                print(gameController.mapStatus[gameController.selectedTile])

            }

            Button {
                gameController.showMapPopUp = false
            } label: {
                Text("Cancel")

            }

        } 
        .frame(width: 250, height: 200)
        .background(.orange)

    }
}

I can print the dictionary at the tapped index value when I select an option on the map popup view and it correctly shows that the dictionary entry has been updated but the honeycomb map doesnt update the image. If i exit out of the map and go back in it then correctly displays the changes that have been made.

I might be missing something very obvious but it doesnt seem to be updating the view unless I reload and I dont know how to force it to update when I change the value. I have looking into objectWillChange etc but I dont really understand how to implement them fully or how to use them.

Any help from anyone is appreciated

   

Chris ponders the ObservedObject:

I might be missing something very obvious
but it doesnt seem to be updating the view unless I reload and
I dont know how to force it to update when I change the value.
I dont really understand how to implement them fully or how to use them.

ObservableObjects are covered by @twoStraws in some very clever lessons and homework assignments.

Any help from anyone is appreciated (passive)
I'd appreciate anyone's help! (Declare your intentions. More Swifty!)

I suggest you take a few days off from your GameController and jump to the 100 Days of SwiftUI lessons covering ObservedObjects.

You'll get much more help from a few of @twoStraws' lessons than you might get from a forum post.

Chris continues:

I dont know how to force it to update when I change the value.

You don't. SwiftUI monitors the values used to build its parent and child views. If any of those @State or @ObservableObject vars changes, SwiftUI redraws the view. You don't change a value, then force the UI to update. There is no force update function in the language.

   

@twoStraw's lessons will help you in other ways as well.

TileView


Break your big problem into a bunch of smaller problems that are easy to solve. Your game is made up of tiles that have icons, text, colors, and rules. Put all those parameters into one struct. Then build a second View struct to display ONE of your tile objects.

// This is how to display ONE tile.
// You can use this one view in TWO different places in this solution.
import SwiftUI
struct TileView: View {
    let tile: GameTile  // The lucky tile.
    var body: some View {
        ZStack {
            Image(systemName: tile.icon)
                .resizable()
                .scaledToFit()
                .foregroundColor(tile.tint)
            Text("\(tile.id)").font(.title)
        }
    }
}

// This defines ONE tile.
struct GameTile: Identifiable, Comparable {
    let id:    Int                    // unique it.
    var icon = String.randomSFSymbol  // seems like a missing String feature. Add it!
    var tint = Color.randomTint       // seems like a missing Color feature. Add it!
    // Required to sort GameTiles by id
    static func < (lhs: GameTile, rhs: GameTile) -> Bool { lhs.id < rhs.id }
}

// Seems like a useful feature to have for this app. Get a random SF Symbol for the tiles.
extension String  {
    static var randomSFSymbol: String {
        ["leaf.fill", "flame.fill", "drop.fill", "cloud.fill", "figure.walk", "heart.fill", "heart.fill", "deskclock"].randomElement()!
    }
}

// Add a missing feature to the Color class.
extension Color {
    static var randomTint: Color {
        [Color.red, .indigo, .yellow, .cyan, .green, .blue].randomElement()!.opacity(0.8)
    }
} // end of new Color feature.

MapView


Your gameContoller is in charge of the tiles and all the game rules. The MapView just paints it on the screen. If any of the tiles in the gameController change, then this view should redraw itself. The View and the Model should always be in sync.

import SwiftUI
struct MapView: View {
    @EnvironmentObject var gameController: GameController
    let gridSpacing: CGFloat =             15
    let tileSize =                         CGSize(width: 50, height: 50)
    @State private var gridColumns =       4
    @State private var showPopover =       false // this is VIEW state, NOT GameController state

    var body: some View {
        let gridArray = Array(repeating: GridItem(.fixed(tileSize.width), spacing: gridSpacing), count: gridColumns)

            VStack{
                Text("How many columns?").font(.callout)
                // Dynamically change the number of columns!
                Picker("Columns", selection: $gridColumns) {
                    ForEach(3...5, id:\.self) { Text("\($0)").font(.headline) }
                }.pickerStyle(SegmentedPickerStyle())
                ScrollView([.horizontal, .vertical], showsIndicators: false) {
                    LazyVGrid(columns: gridArray, spacing: gridSpacing) {
                        // NOTE: Whenever mapTiles is updated, these views are REDRAWN
                        ForEach(gameController.mapTiles.sorted() ) { tile in
                            TileView(tile: tile)
                                .frame(width: tileSize.width, height: tileSize.height)
                                .offset(x: isEvenRow(tileID: tile.id) ? 0 : tileSize.width, y: 0)
                                .onTapGesture {
                                    gameController.tapTile(tile.id)
                                    showPopover.toggle()
                                }
                        }
                    } 
                }// watch the grid rearrange itself. Animation on ScrollView, not the LazyVGrid!
                .animation(.easeIn(duration: 1.0), value: gridColumns)
            }
            .background(.teal.opacity(0.3))
            .popover(isPresented: $showPopover) { NewIconPopup() }
    }

    func isEvenRow( tileID: Int) -> Bool {
        return (tileID-1).quotientAndRemainder(dividingBy: gridColumns).quotient.isMultiple(of: 2)
    }
}

Select a new icon for the Tile


Define a different view to allow your player to change an existing tile. What did they tap? What are their options? Hand the heavy work load over to your Game Controller. That's where game logic belongs! It doesn't belong in a view.

import SwiftUI
struct NewIconPopup: View {
    @EnvironmentObject var gameController: GameController
    @Environment(\.presentationMode) var presentationMode

    var body: some View {
        VStack{
            VStack {
                Text("You tapped:")
                TileView(tile: gameController.selectedTile) // See? Reuse your code in other places!
            }.padding()
            Text("Change to:").padding([.top, .bottom])
            VStack(spacing: 10) {
                OptionView(tile: GameTile(id: 0, icon: "flame.fill",  tint: .blue), description: "Start a fire")
                OptionView(tile: GameTile(id: 0, icon: "leaf.fill",   tint: .blue), description: "Plant crops")
                OptionView(tile: GameTile(id: 0, icon: "drop.fill",   tint: .blue), description: "Fetch water")
                OptionView(tile: GameTile(id: 0, icon: "cloud.bolt.rain.fill",  tint: .blue), description: "Rain Dance")
                OptionView(tile: GameTile(id: 0, icon: "figure.walk", tint: .blue), description: "Walkabout")
            }.padding()
            Button { presentationMode.wrappedValue.dismiss() } // Dismiss the dialog
            label: { Text("Close").font(.largeTitle) }.padding(.top) }
        .frame(width: 400)
        .background(.blue.opacity(0.4))
    }
}

// Special view just to show an option to the player.
struct OptionView: View {
    @EnvironmentObject var gameController: GameController
    var tile:        GameTile
    let description: String
    var body: some View {
        HStack {
            Image(systemName: tile.icon).foregroundColor(tile.tint)
            Text(description)
        }.font(.title3)
            .onTapGesture { gameController.updateTile(to: tile.icon) } // Let the gameController handle the taps.
    }
}

Game Controller


Your game is a collection of GameTile objects. But it has its own rules. Put all of the games rules into your GameController class. The class should define all of your game's intentions such as selecting, updating, removing.

class GameController: ObservableObject{
    @Published var mapTiles:       [GameTile]
    @Published var selectedTileId: Int

    init() {
        mapTiles       = GameController.testTiles
        selectedTileId = 0
    }

    // return copy of selected tile
    var selectedTile: GameTile { mapTiles.first { $0.id == selectedTileId}! }

    // probably a better way to do this
    // when mapTiles is updated, changes are published to ALL views.
    func updateTile(to newIcon: String) {
        // REMOVE the original tile
        mapTiles.remove(at: mapTiles.firstIndex { $0.id == selectedTileId } ?? 0) // dangerous. add more checks
        // ADD new tile to the collection. Use same ID, different ICON
        mapTiles.append(GameTile(id: selectedTileId, icon: newIcon))
    }

    func tapTile(_ tile: Int ) {
        guard (0...mapTiles.count).contains(tile) else { return } // selectedTile should be optional
        selectedTileId = tile // need to guard for valid tiles
    }

// Sample data for testing.
    static var testTiles: [GameTile]  {
        let maxNumberOfTiles     = Int.random(in: 25...31) // How many tiles on the screen?
        var newTiles: [GameTile] = []
        for tileId in (1...maxNumberOfTiles) {
            newTiles.append(GameTile(id: tileId))
        }
        return newTiles
    }
}

HexagonGridApp (Application starting point)


import SwiftUI

@main
struct HexagonGridApp: App {
    @StateObject var gc = GameController() // create a new game controller
    var body: some Scene {
        WindowGroup {
            MapView().environmentObject(gc)  // inject the game controller into your MapView()
        }
    }
}

   

Thanks so much for this! The example code is really useful for giving me a good idea on what I need to go back and look at to make sure I am understanding correctly.

I'll dive back into those lessons.

One thing this straight away has helped me with is confirming what I had thought recently, which is that I should be inserting my Game Controller as a Environment Object instead of an Observed object

   

I added a Picker to the MapView code, allowing you to dynamically change the number of columns. A slow animation shows how the grid elements move to new positions. Have fun!

Also updated the GameController to create a random number of GameTiles for testing.

In the MapView, swap out the default animation for one a bit springy:

// replace
.animation(.easeIn(duration: 1.0), value: gridColumns)
// with
.animation(.interpolatingSpring(mass: 0.95, stiffness: 0.7, damping: 0.85, initialVelocity: 0.35), value: gridColumns)

   

Hacking with Swift is sponsored by RevenueCat

SPONSORED In-app subscriptions are a pain to implement, hard to test, and full of edge cases. RevenueCat makes it straightforward and reliable so you can get back to building your app. Oh, and it's free if your app makes less than $10k/mo.

Learn more

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.