swiftswiftuiwatchos

Toggle() flashes on change


I'm pretty sure this is a pretty simple problem and it's root is in the ForEach(group.films) section having to redraw whenever $config is changed. The issue is that when I change the Toggle value, they all flash quickly off then back on depending on if they're .active or not:

enter image description here

struct Film: Hashable, Codable, Identifiable {
    var id = UUID().uuidString
    let brand: String
    let stock: String
    let reciprocity: Double
    var active = true
}


private var filmsGroupedByBrand: [GroupedFilm] {
    return Dictionary(grouping: config.films, by: { $0.brand })
        .map { (brand, films) in GroupedFilm(brand: brand, films: films) }
        .sorted(by: { $0.brand < $1.brand })
        .filter( { $0.brand != "Custom" && $0.brand != "Empty" })
}

ForEach(filmsGroupedByBrand, id: \.self) { group in
    Section(group.brand) {
        ForEach(group.films) { film in
            if let index = config.films.firstIndex(where: { $0.id == film.id }) {
                Toggle(isOn: $config.films[index].active) {
                    Text(config.films[index].stock)
                }
            }
        }
    }
}

Full code:

//
//  ContentView.swift
//  tesra Watch App
//
//  Created by Joe Scotto on 10/11/24.
//

import SwiftUI

struct Film: Hashable, Codable, Identifiable {
    var id = UUID().uuidString
    let brand: String
    let stock: String
    let reciprocity: Double
    var active = true
}

struct Defaults {
    static let films = [
        Film(brand: "Ilford", stock: "Ilford Delta 100", reciprocity: 1.26),
        Film(brand: "Ilford", stock: "Ilford Delta 400", reciprocity: 1.41),
        Film(brand: "Ilford", stock: "Ilford Delta 3200", reciprocity: 1.33),
        Film(brand: "Ilford", stock: "Ilford FP4 Plus", reciprocity: 1.26),
        Film(brand: "Ilford", stock: "Ilford HP5 Plus", reciprocity: 1.31),
        Film(brand: "Ilford", stock: "Ilford Ortho Plus", reciprocity: 1.25),
        Film(brand: "Ilford", stock: "Ilford Pan F Plus", reciprocity: 1.33),
        Film(brand: "Ilford", stock: "Ilford SFX 200", reciprocity: 1.43),
        Film(brand: "Ilford", stock: "Ilford XP2 Super", reciprocity: 1.31),
        Film(brand: "Kentmere", stock: "Kentmere Pan 100", reciprocity: 1.26),
        Film(brand: "Kentmere", stock: "Kentmere Pan 400", reciprocity: 1.30),
        Film(brand: "Kodak", stock: "Kodak Portra 160", reciprocity: 1.35),
        Film(brand: "Kodak", stock: "Kodak Portra 400", reciprocity: 1.35),
        Film(brand: "Empty", stock: "No films enabled", reciprocity: 1.00, active: false),
        Film(brand: "Custom", stock: "Custom film", reciprocity: 1.3, active: false)
    ]
}

class Config: ObservableObject {
    @Published var films = Defaults.films
}


struct GroupedFilm: Hashable {
    let brand: String
    let films: [Film]
}

struct ContentView: View {
    @EnvironmentObject var config: Config
    
    private var filmsGroupedByBrand: [GroupedFilm] {
        return Dictionary(grouping: self.config.films, by: { $0.brand })
            .map { (brand, films) in GroupedFilm(brand: brand, films: films) }
            .sorted(by: { $0.brand < $1.brand })
            .filter( { $0.brand != "Custom" && $0.brand != "Empty" })
    }
    
    var body: some View {
        VStack {
            List {
                ForEach(filmsGroupedByBrand, id: \.self) { group in
                    Section(group.brand) {
                        ForEach(group.films, id: \.id) { film in
                            if let index = config.films.firstIndex(where: { $0.id == film.id }) {
                                Toggle(isOn: $config.films[index].active) {
                                    Text(config.films[index].stock)
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

#Preview {
    ContentView()
        .environmentObject(Config())
}

Solution

  • The problem is that filmsGroupedByBrand is a computed property and it makes use of the published property films in Config so every time film changes the computed property is recalculated and you get the flashing behaviour in the view.

    But if we look at the code in the computed property it is really a static property or at least not updated by any changes in the view so I moved it to the Config class instead and changed it into a stored property

    class Config: ObservableObject {
        @Published var films = Defaults.films
        let filmsGroupedByBrand: [GroupedFilm]
    
        init() {
            filmsGroupedByBrand = Dictionary(grouping: Defaults.films, by: { $0.brand })
                .map { brand, films in GroupedFilm(brand: brand, films: films) }
                .sorted(by: { $0.brand < $1.brand })
                .filter { $0.brand != "Custom" && $0.brand != "Empty" }
        }
    }
    

    Now that this is not a computed property it will not be recalculated for each change and the view will behave as expected without any flashing toggles.

    An alternative is of course to instead have the stored property in the view and as above set its value in the init, depending on how your real code looks one might be better than the other.