Antonio Cassia
Antonio Cassia

Reputation: 33

SwiftUI, why a part of the view did not refresh? @State variable not updating

this is a recreation of the problem that I have in my SwiftUI code. Suppose to have a list of devices and for each I have some connectivity info (enabled or not).

Using a classic List in SwiftUI, for each device there is a DeviceView that will be shown. For convenience, I want to add a couple of arrows on the top to switch between devices without returning back to the list every time (to understand, like the Mail app in iOS). So, instead of calling the DeviceView from the list, I before call a MotherDeviceView that store the info of the current device. When I tap the arrows on the top, the MotherDeviceView call again DeviceView and the view is updated.

struct Device: Identifiable {
    var id = Int()
    var deviceName = String()

    var isWifiOn = Bool()
    var isCellularOn = Bool()
    var isBluetoothOn = Bool()
}

let devices: [Device] = [
    Device(id: 0, deviceName: "iPhone", isWifiOn: true, isCellularOn: false, isBluetoothOn: true),
    Device(id: 1, deviceName: "iPod", isWifiOn: false, isCellularOn: false, isBluetoothOn: true),
    Device(id: 2, deviceName: "iPad", isWifiOn: false, isCellularOn: true, isBluetoothOn: false)
]

// main view, with list of devices
struct DeviceTab: View {
    var body: some View {
        NavigationView {
            List(devices) { device in
                NavigationLink(destination: MotherDeviceView(device: device)){
                    Text(device.deviceName)
                }
            }.navigationBarTitle(Text("Devices"))
        }
    }
}

// the list call this view and pass the device to DeviceView.
// Using this view is possible to go to the other device using the arrows on the top
// without returning to the list
struct MotherDeviceView: View {
    @State var device: Device

    var body: some View {
        VStack{
            DeviceView(device: $device)
        }
        .navigationBarItems(trailing: arrows)
    }

    private var arrows: some View {
        HStack{
            Button(action: { self.previous() },
                    label: { Image(systemName: "chevron.up") } )
                .padding(.horizontal, 10)
            Button(action: { self.next() },
                   label: { Image(systemName: "chevron.down") } )
        }
    }

    func previous() {
        if device.id == 0 { return }
        device = devices[device.id - 1]
    }

    func next() {
        if device.id == 2 { return }
        device = devices[device.id + 1]
    }

}

// DeviceView
struct DeviceView: View {
    @Binding var device: Device

    var body: some View {
        VStack{
            Spacer()
            Text("This is the device number: " + String(device.id))
            Spacer()
            ToggleSection(dev: $device)
            Spacer()
        }.navigationBarTitle(device.deviceName)
    }
}

// Toggle part of DeviceView
struct ToggleSection: View {
    @Binding var dev: Device

    @State var toggleOn: [Bool] = [false, false, false]

    var body: some View {

        VStack{

            Text(dev.deviceName)
                .fontWeight(.bold)

            Toggle(isOn: $toggleOn[0], label: { Text("Wifi") } )
                .padding()

            Toggle(isOn: $toggleOn[1], label: { Text("Cellular") } )
                .padding()

            Toggle(isOn: $toggleOn[2], label: { Text("Bluetooth") } )
                .padding()

        }
        .onAppear{ self.initialConfigToggle() }
        // with onAppear is okay, but when I use the top arrows, this section does not update
    }

    private func initialConfigToggle() {
        self.toggleOn[0] = self.dev.isWifiOn
        self.toggleOn[1] = self.dev.isCellularOn
        self.toggleOn[2] = self.dev.isBluetoothOn
    }

}

But there is a section in DeviceView, ToggleSection, that will not update, and I don't know how to resolve this. Maybe forcing an update? But I don't think is the correct answer (and however I even don't know how to force an update).

I think that if in ToggleSection there is @Binding and not @State maybe is the right answer, but this does not work. This example will work (except for the toggle not updated)

Thanks in advance!

Upvotes: 3

Views: 2171

Answers (1)

Asperi
Asperi

Reputation: 258247

Here is possible approach with added explicit view model for device storage & joining all views in the way to make modifications exactly in that storage (avoiding coping device values).

Tested with Xcode 11.4 / iOS 13.4

Modified code:

import SwiftUI
import Combine

struct Device: Identifiable {
    var id = Int()
    var deviceName = String()

    var isWifiOn = Bool()
    var isCellularOn = Bool()
    var isBluetoothOn = Bool()
}

class DeviceStorage: ObservableObject {
    @Published var devices: [Device] = [
        Device(id: 0, deviceName: "iPhone", isWifiOn: true, isCellularOn: false, isBluetoothOn: true),
        Device(id: 1, deviceName: "iPod", isWifiOn: false, isCellularOn: false, isBluetoothOn: true),
        Device(id: 2, deviceName: "iPad", isWifiOn: false, isCellularOn: true, isBluetoothOn: false)
    ]
}

// main view, with list of devices
struct DeviceTab: View {
    @ObservedObject var vm = DeviceStorage() // for demo, can be injected via init
    var body: some View {
        NavigationView {
            List(Array(vm.devices.enumerated()), id: \.element.id) { i, device in
                    NavigationLink(destination: MotherDeviceView(vm: self.vm, selectedDevice: i)) {
                        Text(device.deviceName)
                }
            }.navigationBarTitle(Text("Devices"))
        }
    }
}

struct MotherDeviceView: View {
    @ObservedObject var vm: DeviceStorage // reference type, so have same
    @State private var selected: Int      // selection index

    init(vm: DeviceStorage, selectedDevice: Int) {
        self.vm = vm
        self._selected = State<Int>(initialValue: selectedDevice)
    }

    var body: some View {
        VStack{
            DeviceView(device: $vm.devices[selected]) // bind to selected
        }
        .navigationBarItems(trailing: arrows)
    }

    private var arrows: some View {
        HStack{
            Button(action: { self.previous() },
                    label: { Image(systemName: "chevron.up") } )
                .padding(.horizontal, 10)
            Button(action: { self.next() },
                   label: { Image(systemName: "chevron.down") } )
        }
    }

    func previous() {
        if vm.devices[selected].id == 0 { return }
        selected -= 1                              // change selection
    }

    func next() {
        if vm.devices[selected].id == 2 { return }
        selected += 1                              // change selection
    }

}

// DeviceView
struct DeviceView: View {
    @Binding var device: Device

    var body: some View {
        VStack{
            Spacer()
            Text("This is the device number: " + String(device.id))
            Spacer()
            ToggleSection(dev: $device)
            Spacer()
        }.navigationBarTitle(device.deviceName)
    }
}

// Toggle part of DeviceView
struct ToggleSection: View {
    @Binding var dev: Device

    var body: some View {

        VStack{

            Text(dev.deviceName)
                .fontWeight(.bold)

            // all below bound directly to model !!!

            Toggle(isOn: $dev.isWifiOn, label: { Text("Wifi") } )
                .padding()

            Toggle(isOn: $dev.isCellularOn, label: { Text("Cellular") } )
                .padding()

            Toggle(isOn: $dev.isBluetoothOn, label: { Text("Bluetooth") } )
                .padding()

        }
    }
}

Upvotes: 1

Related Questions