swiftuiswiftui-view

Don't redraw some of the view structure


Consider the following code that draws a grid of randomly colored rectangles on the screen, with a Circle that moves to right by responding to a timer:

struct ContentView: View {
    let width: CGFloat = 400
    let height: CGFloat = 400
    let spacing: CGFloat = 8.0
    let numRows = 20
    var tileSize: CGFloat {
        height / 5
    }
    
    let timer = Timer.publish(every: 3.0, on: .main, in: .common).autoconnect()
    @State private var circleX: CGFloat = 0.0
    
    var body: some View {
        let numColumns = Int(tileSize)
        
        ZStack {
            Grid(horizontalSpacing: spacing, verticalSpacing: spacing) {
                ForEach(0..<numRows, id: \.self) { rowIndex in
                    GridRow {
                        ForEach(0..<numColumns, id: \.self) { colIndex in
                            Rectangle()
                                .fill(Color.random)
                                .frame(width: tileSize, height: tileSize)
                        }
                    }
                }
            }
            
            
            Circle()
                .fill(.red)
                .frame(width:50, height: 50)
                .offset(x: circleX, y: 0)
                
        }
        .onReceive(timer) { time in
            circleX += 10
        }
        
    }
}

#Preview {
    ContentView()
}

extension Color {
    static var random: Color {
        Color(red: .random(in: 0...1),
              green: .random(in: 0...1),
              blue: .random(in: 0...1))
    }
}

The problem is, I don't want the rectangles to change color. I want them to get a random color to start with and stay that way. What currently happens is that every time the timer is fired, the rectangles all get a new color. The whole view is being re-rendered even though I'm only changing a property that applies to the circle. I've tried putting the .onReceive modifier on the Circle itself but that doesn't help.


Solution

  • Extract a separate view for the randomly-coloured rectangle.

    struct RandomlyColoredRectangle: View {
        var body: some View {
            Rectangle()
                .fill(Color.random)
        }
    }
    

    This works because the Color.random call is in another body. When SwiftUI calls ContentView.body, RandomlyColoredRectangle.body is not called because no dependencies of RandomlyColoredRectangle has changed (in fact it has no dependencies).


    The more general way to prevent view updates is to conform to Equatable:

    struct RandomlyColoredRectangle: View, Equatable {
        // since this struct has no properties, the automatically generated '=='
        // always returns true
    
        var body: some View {
            Rectangle()
                .fill(Color.random)
        }
    }
    

    and use RandomlyColoredRectangle like this:

    RandomlyColoredRectangle().equatable()
    

    equatable() makes it so that the view only updates when the new RandomlyColoredRectangle is unequal to the old RandomlyColoredRectangle. Since our == always return true, the view never updates.

    This is more flexible as you can implement == in your own way, so that the view only updates when you want it to.