3

I have a SwiftUI list which presents a detail view/pushes to the navigation when a cell is tapped:

import SwiftUI

struct DevicesInRangeList: View {
    @ObservedObject var central = Central()

    var body: some View {
        NavigationView {
            List(central.peripheralsInRange) { peripheral in
                NavigationLink(destination: DeviceView(peripheral: peripheral).onAppear {
                    self.central.connect(peripheral: peripheral)
                }.onDisappear {
                    self.central.disconnect(peripheral: peripheral)
                }) {
                    DeviceRow(deviceID: peripheral.deviceID, name: peripheral.name)
                }
            }.onAppear {
                self.central.scanning = true
            }.onDisappear {
                self.central.scanning = false
            }.navigationBarTitle("Devices in range")
        }
    }
}

If I tap a row, the detail is displayed. If the peripheral disconnects it is removed from the peripheralsInRange array and the row is removed – but the detail is still displayed. How can the detail be removed when the associated row is removed?


Edit: After Asperi's answer I have the following, which still doesn't work:

struct DevicesInRangeList: View {
    @ObservedObject var central = Central()

    @State private var localPeripherals: [Peripheral] = []

    @State private var activeDetails = false
    var body: some View {
        NavigationView {
            List(localPeripherals, id: \.self) { peripheral in
                NavigationLink(destination:
                    DeviceView(peripheral: peripheral)
                        .onReceive(self.central.$peripheralsInRange) { peripherals in
                            if !peripherals.contains(peripheral) {
                                self.activeDetails = false
                            }
                        }
                        .onAppear {
                            self.central.connect(peripheral: peripheral)
                        }
                        .onDisappear {
                            self.central.disconnect(peripheral: peripheral)
                        }
                , isActive: self.$activeDetails) {
                    DeviceRow(deviceID: peripheral.deviceID, name: peripheral.name)
                }
            }.onReceive(central.$peripheralsInRange) { peripherals in
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                    self.localPeripherals = peripherals
                }
            }.onAppear {
                self.central.scanning = true
                self.localPeripherals = self.central.peripheralsInRange
            }.onDisappear {
                self.central.scanning = false
            }.navigationBarTitle("Devices in range")
        }
    }
}
Nick
  • 3,958
  • 4
  • 32
  • 47
  • Can you show what `peripheral` is? Is it `ObservableObject`? Does it known itself that it disappears? – Asperi Jan 22 '20 at 14:36
  • No, but central.peripheralsInRange is observable, which is updating the list correctly. I don't know what property I would need to publish on Peripheral to indicate if it should be shown, and how that would be observed in SwiftUI? – Nick Jan 22 '20 at 15:10
  • I've found reason & fix, that was interesting adventure. Please see updated answer. – Asperi Jan 22 '20 at 18:13

2 Answers2

1

The best way is check existence od data before displaying it. I adopted apple's master / demo to show how to do it. In this template application they use @State var as source of records, but the idea is the same. Check existence of "record" IN DETAIL VIEW.

import SwiftUI

private let dateFormatter: DateFormatter = {
    let dateFormatter = DateFormatter()
    dateFormatter.dateStyle = .medium
    dateFormatter.timeStyle = .medium
    return dateFormatter
}()

struct ContentView: View {
    @State private var dates = [Date]()

    var body: some View {
        NavigationView {
            MasterView(dates: $dates)
                .navigationBarTitle(Text("Master"))
                .navigationBarItems(
                    leading: EditButton(),
                    trailing: Button(
                        action: {
                            withAnimation { self.dates.insert(Date(), at: 0) }
                        }
                    ) {
                        Image(systemName: "plus")
                    }
                )
            DetailView(dates: $dates).navigationBarTitle(Text("Detail"))
        }.navigationViewStyle(DoubleColumnNavigationViewStyle())
    }
}

struct MasterView: View {
    @Binding var dates: [Date]

    var body: some View {
        List {
            ForEach(dates, id: \.self) { date in
                NavigationLink(
                    destination: DetailView(dates: self._dates, selectedDate: date).navigationBarTitle(Text("Detail"))
                ) {
                    Text("\(date, formatter: dateFormatter)")
                }
            }.onDelete { indices in
                indices.forEach { self.dates.remove(at: $0) }
            }
        }
    }
}

struct DetailView: View {
    @Binding var dates: [Date]
    var selectedDate: Date?

    var body: some View {
            if let selectedDate = selectedDate, dates.contains(selectedDate) {
                return Text("\(selectedDate, formatter: dateFormatter)")
            } else {
                return Text("Detail view content goes here")
            }
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

enter image description here

user3441734
  • 16,722
  • 2
  • 40
  • 59
  • for sure yes! I put here the snapshot of iPad, to be easy to see the result :-) you can try it on landscape iPhone (on portrait, you don't see detail, until selected, so it must exists. – user3441734 Jan 22 '20 at 17:35
  • But on iPhone, if you are viewing the detail (without master displayed) and the date is removed (by external event) I don't think this would pop back to the master list? – Nick Jan 22 '20 at 17:41
  • it could be done, why not? but think about, is it good idea? it is much better to tell the user that "device was disconnected" or something like that. if you dismiss detail view, you have to use different approach on iPad, iPhone X, iPhone SE, it depends on device orientation ... – user3441734 Jan 22 '20 at 17:50
  • In this instance, and also for the sake of the question and understanding, I would like to pop back to the master / remove the detail – Nick Jan 22 '20 at 18:24
  • are you wondering how to programmatically dismiss your detail view? – user3441734 Jan 22 '20 at 18:32
  • Yes I want to dismiss the detail view when the associated item is removed from the array – Nick Jan 22 '20 at 18:32
  • see https://swiftui-lab.com/bug-navigationlink-isactive/ (it is different question). and do it based on the same condition as in my answer – user3441734 Jan 22 '20 at 18:35
0

Well... will be a bit long, but it worth... I've reproduced the defect behaviour on simplified model... and here is the reason of issue

2020-01-22 19:53:41.008064+0200 Test[5539:983123] [TableView] Warning once only: UITableView was told to layout its visible cells and other contents without being in the view hierarchy (the table view or one of its superviews has not been added to a window). This may cause bugs by forcing views inside the table view to load and perform layout without accurate information (e.g. table view bounds, trait collection, layout margins, safe area insets, etc), and will also cause unnecessary performance overhead due to extra layout passes. Make a symbolic breakpoint at UITableViewAlertForLayoutOutsideViewHierarchy to catch this in the debugger and see what caused this to occur, so you can avoid this action altogether if possible, or defer it until the table view has been added to a window. Table view: <_TtC7SwiftUIP33_BFB370BA5F1BADDC9D83021565761A4925UpdateCoalescingTableView: 0x7fd095042600; baseClass = UITableView; frame = (0 0; 375 667); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = ; layer = ; contentOffset: {0, -116}; contentSize: {375, 400.5}; adjustedContentInset: {116, 0, 0, 0}; dataSource: <_TtGC7SwiftUIP13$7fff2c6b223419ListCoreCoordinatorGVS_20SystemListDataSourceOs5Never_GOS_19SelectionManagerBoxS2___: 0x7fd093f62b60>>

This exception breaks navigation stack so details view is not closed either by itself or forcefully by isActive state.

So here is initial code that reproduces the issue (once started just navigate any row and wait for 20 secs)

// view model holding some sequence of data to be shown in List
class TestedModel: ObservableObject {
    @Published var originalRange = [1, 2, 3, 4, 5, 6, 7, 8, 9]
}

// simple detail view 
struct DetachedDetailView: View {
    let item: Int
    var body: some View {
        Text("Details of item \(item)")
    }
}

// Issue demo view
struct TestNavigationLinkDestruction_Issue: View {
    @ObservedObject var model = TestedModel()

    var body: some View {
        NavigationView {
            List(model.originalRange, id: \.self) { item in
                NavigationLink("Item \(item)", destination:
                    DetachedDetailView(item: item))
            }
        }
        .onAppear {
            // >> by this simulated async update of List while in Details
            DispatchQueue.main.asyncAfter(deadline: .now() + 20) {
                self.model.originalRange = [10, 20, 30, 40, 50, 60, 70, 80, 90]
            }
        }
    }
}

And here is a solution... the idea is separate in time update of List content and moment of making decision is it needed to close details

enter image description here

struct TestNavigationLinkDestruction_Fixed: View {
    @ObservedObject var model = TestedModel()

    @State private var selected: Int? = nil
    @State private var localStorage: [Int] = []


    var body: some View {
        NavigationView {
            // List locally stored items
            List(localStorage, id: \.self) { item in
                NavigationLink("Item \(item)", destination:
                    DetachedDetailView(item: item)
                        .onReceive(self.model.$originalRange) { items  in
                            if !items.contains(item) {
                                self.selected = nil // !!! unwind at once
                            }
                        }
                , tag:item, selection: self.$selected)
            }
            .onReceive(self.model.$originalRange) { items  in
                DispatchQueue.main.async {
                    self.localStorage = items // !!! postpone local data update
                }
            }
        }
        .onAppear {
            self.localStorage = self.model.originalRange // ! initial load from model

            // >>> simulate async data update
            DispatchQueue.main.asyncAfter(deadline: .now() + 20) {
                self.model.originalRange = [10, 20, 30, 40, 50, 60, 70, 80, 90]
            }
        }
    }
}

So.. all you need is to adopt above to your code, I'm sure it's feasible.

Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Your assumptions are correct. `self.activeDetails` gets set to false on disconnect, but the details view does not disappear. – Nick Jan 22 '20 at 16:31
  • Actually although it is set, the value of `self.activeDetails` does not change. Perhaps it is fixed to the state of the NavigationLink so is not possible to change? – Nick Jan 22 '20 at 16:44
  • I tried this, but it still doesn't work. activeDetails is set false before localPeripherals is updated, but it doesn't dismiss the detail. activeDetails is still true when inspected in the asyncAfter block. – Nick Jan 23 '20 at 17:45
  • @Nick, there is no `activeDetails` in my updated code, the approach is new, and as it contains only stub models it was tested and works with Xcode 11.2 / iOS 13.2. – Asperi Jan 23 '20 at 17:51
  • But I can't understand what is different, practically. activeDetails is set just like your selected property is – Nick Jan 23 '20 at 18:18