3

UIKit used to support TableView Cell that enabled a Blue info/disclosure button. The following was generated in SwiftUI, however getting the underlying functionality to work is proving a challenge for a beginner to SwiftUI.

Visually everything is fine as demonstrated below:

Generated by the following code:

struct Session: Identifiable {
    let date: Date
    let dir: String
    let instrument: String
    let description: String
    var id: Date { date }
}

final class SessionsData: ObservableObject {
    @Published var sessions: [Session]
        
    init() {
        sessions = [Session(date: SessionsData.dateFromString(stringDate: "2016-04-14T10:44:00+0000"),dir:"Rhubarb", instrument:"LCproT", description: "brief Description"),
                    Session(date: SessionsData.dateFromString(stringDate: "2017-04-14T10:44:00+0001"),dir:"Custard", instrument:"LCproU", description: "briefer Description"),
                    Session(date: SessionsData.dateFromString(stringDate: "2018-04-14T10:44:00+0002"),dir:"Jelly", instrument:"LCproV", description: " Description")
        ]
    }
    static func dateFromString(stringDate: String) -> Date {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en_US_POSIX") // set locale to reliable US_POSIX
        dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
        return dateFormatter.date(from:stringDate)!
    }
}

struct SessionList: View {
    @EnvironmentObject private var sessionData: SessionsData
    
    var body: some View {
        NavigationView {
            List {
                ForEach(sessionData.sessions) { session in
                    SessionRow(session: session )
                }
            }
            .navigationTitle("Session data")
        }
        // without this style modification we get all sorts of UIKit warnings
        .navigationViewStyle(StackNavigationViewStyle())
    }
}

struct SessionRow: View {
    var session: Session
    
    @State private var presentDescription = false
    
    var body: some View {
        HStack(alignment: .center){
            VStack(alignment: .leading) {
                Text(session.dir)
                    .font(.headline)
                    .truncationMode(.tail)
                    .frame(minWidth: 20)

                Text(session.instrument)
                    .font(.caption)
                    .opacity(0.625)
                    .truncationMode(.middle)
            }
            Spacer()
            // SessionGraph is a place holder for the Graph data.
            NavigationLink(destination: SessionGraph()) {
                // if this isn't an EmptyView then we get a disclosure indicator
                EmptyView()
            }
            // Note: without setting the NavigationLink hidden
            // width to 0 the List width is split 50/50 between the
            // SessionRow and the NavigationLink. Making the NavigationLink
            // width 0 means that SessionRow gets all the space. Howeveer
            // NavigationLink still works
            .hidden().frame(width: 0)

            Button(action: { presentDescription = true
                print("\(session.dir):\(presentDescription)")
            }) {
                Image(systemName: "info.circle")
            }
            .buttonStyle(BorderlessButtonStyle())

            NavigationLink(destination: SessionDescription(),
                           isActive: $presentDescription) {
                EmptyView()
            }
            .hidden().frame(width: 0)
        }
        .padding(.vertical, 4)
    }
}

struct SessionGraph: View {
    var body: some View {
        Text("SessionGraph")
    }
}

struct SessionDescription: View {
    var body: some View {
        Text("SessionDescription")
    }
}

The issue comes in the behaviour of the NavigationLinks for the SessionGraph. Selecting the SessionGraph, which is the main body of the row, propagates to the SessionDescription! hence Views start flying about in an un-controlled manor.

I've seen several stated solutions to this issue, however none have worked using XCode 12.3 & iOS 14.3

Any ideas?

eklektek
  • 1,083
  • 1
  • 16
  • 31

1 Answers1

2

When you put a NavigationLink in the background of List row, the NavigationLink can still be activated on tap. Even with .buttonStyle(BorderlessButtonStyle()) (which looks like a bug to me).

A possible solution is to move all NavigationLinks outside the List and then activate them from inside the List row. For this we need @State variables holding the activation state. Then, we need to pass them to the subviews as @Binding and activate them on button tap.

Here is a possible example:

struct SessionList: View {
    @EnvironmentObject private var sessionData: SessionsData
    
    // create state variables for activating NavigationLinks
    @State private var presentGraph: Session?
    @State private var presentDescription: Session?

    var body: some View {
        NavigationView {
            List {
                ForEach(sessionData.sessions) { session in
                    SessionRow(
                        session: session,
                        presentGraph: $presentGraph,
                        presentDescription: $presentDescription
                    )
                }
            }
            .navigationTitle("Session data")
            // put NavigationLinks outside the List
            .background(
                VStack {
                    presentGraphLink
                    presentDescriptionLink
                }
            )
        }
        .navigationViewStyle(StackNavigationViewStyle())
    }
    
    @ViewBuilder
    var presentGraphLink: some View {
        // custom binding to activate a NavigationLink - basically when `presentGraph` is set
        let binding = Binding<Bool>(
            get: { presentGraph != nil },
            set: { if !$0 { presentGraph = nil } }
        )
        // activate the `NavigationLink` when the `binding` is `true` 
        NavigationLink("", destination: SessionGraph(), isActive: binding)
    }
    
    @ViewBuilder
    var presentDescriptionLink: some View {
        let binding = Binding<Bool>(
            get: { presentDescription != nil },
            set: { if !$0 { presentDescription = nil } }
        )
        NavigationLink("", destination: SessionDescription(), isActive: binding)
    }
}
struct SessionRow: View {
    var session: Session

    // pass variables as `@Binding`...
    @Binding var presentGraph: Session?
    @Binding var presentDescription: Session?

    var body: some View {
        HStack {
            Button {
                presentGraph = session // ...and activate them manually
            } label: {
                VStack(alignment: .leading) {
                    Text(session.dir)
                        .font(.headline)
                        .truncationMode(.tail)
                        .frame(minWidth: 20)

                    Text(session.instrument)
                        .font(.caption)
                        .opacity(0.625)
                        .truncationMode(.middle)
                }
            }
            .buttonStyle(PlainButtonStyle())
            Spacer()
            Button {
                presentDescription = session
                print("\(session.dir):\(presentDescription)")
            } label: {
                Image(systemName: "info.circle")
            }
            .buttonStyle(PlainButtonStyle())
        }
        .padding(.vertical, 4)
    }
}
pawello2222
  • 46,897
  • 22
  • 145
  • 209
  • it works, thanks. Using code I need to understand! How would you recommend passing the chosen session to either of the subviews? I had to create defaultSession to handle when presentGraph or presentDescription are nil thus ?? defaultSession passed to the constructor of each subview. – eklektek Mar 08 '21 at 09:20
  • 1
    @user2196409 In your code you weren't passing anything to `SessionGraph` nor `SessionDescription`, so I didn't consider this scenario. The simplest way would be to just have `var session: Session?` in the subviews. – pawello2222 Mar 08 '21 at 10:00
  • Sorry as I'm sure you've got better things to-do, however any insight into how this code is working would be great. I don't get the matching of a link to a session. Or some recommended reading. – eklektek Mar 08 '21 at 13:27
  • 1
    @user2196409 Of course, I added more details. I hope it's clearer now. The *matching of a link to a session* is a way to know which row was clicked and which session should be passed to subviews (if needed). – pawello2222 Mar 08 '21 at 14:21
  • Hmm! it's still a tad magical. I guess key is this init - Binding(get: escaping () -> Value, set: escaping (Value) -> Void) which is "monitoring" the session Value. Thus when either session != nil the NavigationLink is triggered. I have a lot to learn! Thanks. – eklektek Mar 08 '21 at 19:56