Search code examples
swiftuiswiftui-navigationlinkswiftui-navigationview

SwiftUI List rows with INFO button


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?


Solution

  • 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)
        }
    }