skip to Main Content

I am trying to persist the PlayerBar view at the bottom of detail screen of the NavigationSplitView across the various views of the NavigationStack when tapping on a Movie item.

Right now, tapping on a movie item and navigating to a detail screen of the movie causes the player bar to be removed as well and is NOT persisted at the bottom of the screen.

Basically, I am trying to go for the mini player behaviour in the iPad version of the Music app.

struct ContentView: View {
    let movies = ["Movie 1", "Movie 2", "Movie 3"]
    var body: some View {
        NavigationSplitView {
            Text("Categories goes here")
        } detail: {
            PlayerBar {
                NavigationStack {
                    List(movies, id: .self) { movie in
                        NavigationLink {
                            Text("Movie detail for (movie)")
                        } label: {
                            Text(movie)
                        }
                    }
                    .navigationTitle("Movies")
                }
            }
        }
    }
}

struct PlayerBar<Content: View>: View {
    var content: () -> Content
    var body: some View {
        ZStack(alignment: .bottom) {
            content()
            
            Color.blue
                .frame(height: 50)
                .clipShape(.rect(cornerRadius: 15))
        }
        .padding()
    }
}

2

Answers


  1. Chosen as BEST ANSWER

    Thanks to Benzy Neez's answer, managed to write a slightly modified version of the answer here where PlayerBar still takes in a content closure but dynamically updates based on the column visibility.

    Following answer is compatible with iOS 16 and up.

    The only issue I am having right now is that the animation of the player bar expanding and shrinking is not really in sync with the column being dismissed and brought back.

    Happy to get any feedback for further improvements.

    struct ContentView: View {
        @State private var splitColumn: NavigationSplitViewVisibility = .doubleColumn
        @State private var columnWidth: CGFloat = .zero
        
        let movies = ["Movie 1", "Movie 2", "Movie 3"]
        var body: some View {
            PlayerBar(splitColumn: splitColumn, columnWidth: columnWidth) {
                NavigationSplitView(columnVisibility: $splitColumn.animation(.spring(duration: 0.4))) {
                    Text("Categories goes here")
                        .frame(maxWidth: .infinity)
                        .onGeometryChange(for: CGFloat.self) { proxy in
                            proxy.size.width
                        } action: { newValue in
                            columnWidth = newValue
                        }
                } detail: {
                    NavigationStack {
                        List(movies, id: .self) { movie in
                            NavigationLink {
                                Text("Movie detail for (movie)")
                            } label: {
                                Text(movie)
                            }
                        }
                        .navigationTitle("Movies")
                    }
                }
            }
        }
    }
    
    struct PlayerBar<Content: View>: View {
        let splitColumn: NavigationSplitViewVisibility
        let columnWidth: CGFloat
        
        var content: () -> Content
        var body: some View {
            GeometryReader { geo in
                ZStack(alignment: .bottomTrailing) {
                    content()
                    
                    Color.blue
                        .frame(height: 50)
                        .clipShape(.rect(cornerRadius: 15))
                        .padding()
                        .frame(width: splitColumn == .detailOnly ? geo.size.width : geo.size.width - columnWidth)
                }
            }
        }
    }
    

  2. If you attach the PlayerBar to the NavigationSplitView using .safeAreaInset(edge: .bottom) then it stays on top of the nested NavigationStack. However, it also stays on top of the sidebar when it is expanded, which is not ideal.

    To work around the problem of the PlayerBar covering the sidebar, a mask can be added to PlayerBar when the sidebar is showing, to mask out the part that is covering the sidebar:

    • The presence of the sidebar can be detected by supplying the NavigationSplitView with a binding to the column visibility.

    • The width to be masked out should correspond to the width of the sidebar. This can be measured using an .onGeometryChange modifier on the sidebar content.

    • It looks best if the masking change uses an animation that is similar in speed and style to the native animation used for revealing the sidebar. Using .spring(duration: 0.3) works quite well.

    In the updated example below, PlayerBar has been changed to a simple view, rather than a generic wrapper for some other content.

    struct ContentView: View {
        let movies = ["Movie 1", "Movie 2", "Movie 3"]
        @State private var sidebarWidth = CGFloat.zero
        @State private var columnVisibility = NavigationSplitViewVisibility.automatic
    
        var body: some View {
            NavigationSplitView(columnVisibility: $columnVisibility) {
                Text("Categories goes here")
                    .frame(maxWidth: .infinity)
                    .onGeometryChange(for: CGFloat.self) { proxy in
                        proxy.size.width
                    } action: { width in
                        sidebarWidth = width
                    }
            } detail: {
                NavigationStack {
                    // ... content as before
                }
            }
            .safeAreaInset(edge: .bottom) {
                PlayerBar()
                    .mask {
                        Color.black
                            .padding(.leading, columnVisibility == .detailOnly ? 0 : sidebarWidth)
                            .animation(.spring(duration: 0.3), value: columnVisibility)
                    }
            }
        }
    }
    
    struct PlayerBar: View {
        var body: some View {
            Color.blue
                .frame(height: 50)
                .clipShape(.rect(cornerRadius: 15))
                .padding()
        }
    }
    

    Animation

    Ps. You referred to the Music app on iPad – it looks like this is using something more in the style of a TabView for the nested detail, rather than a NavigationStack.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search