LoginSignup
2
1

More than 1 year has passed since last update.

Apple Musicみたいなナビゲーションバーを作る

Posted at

完成イメージ

  • 画面上部にでっかく画像を表示する(アルバムのアートワークとか)
  • その画像の下の方にページタイトルを表示する(アルバム名とか)
  • スクロールしていくと、だんだん画像が暗くなっていく
  • 画像のスクロール量をページ自体のスクロール量よりも小さくすることで、画像が少し奥にあるような視差効果をつくる
  • 画像の下部にあるページタイトルが画面から消えるぐらいのタイミングで、タイトルを画面上部に固定する
    download.gif

画像とタイトルとその下のListを作る

struct HogeView2: View {
    var body: some View {
        ScrollView {
            VStack(spacing: 0) {
                ZStack(alignment: .bottomLeading) {
                    Image("Wallpaper 1")
                        .resizable()
                        .frame(height: 400)
                        .frame(maxWidth: .infinity)
                    Text("Page Title")
                        .font(.largeTitle).bold()
                        .foregroundColor(.white)
                        .padding(.horizontal, 10)
                        .padding(.vertical, 5)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .background(LinearGradient(colors: [.clear, .black.opacity(0.7)], startPoint: .top, endPoint: .bottom))
                }
                .frame(width: .infinity)
                
                VStack {
                    ForEach(0 ..< 30) { item in
                        RoundedRectangle(cornerRadius: 10)
                            .fill(.gray)
                            .frame(height: 100)
                            .padding(10)
                    }
                }
                .background(.white)
            }
        }
        .ignoresSafeArea()
    }
}

download.gif

スクロール量を取得する

  • ScrollOffsetPreferenceKeyを作成
  • ScrollViewの一個内側の要素のbackgroundGeometryReaderを配置し、さらにその内側に透明なpreferenceを設定した要素を配置する
  • onPreferenceChangeイベントでスクロール量を取得し、Stateを更新する
  • Yスクロール量を保持するStateを作成し、とりあえずTextで表示してみる
struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGPoint = .zero
    
    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
    }
}

struct HogeView2: View {
    @State var scrollOffset: CGFloat = 0
    
    var body: some View {
        ScrollView {
            VStack(spacing: 0) {
                ZStack(alignment: .bottomLeading) {
                    Image("Wallpaper 1")
                        .resizable()
                        .frame(height: 400)
                        .frame(maxWidth: .infinity)
                    Text("Page Title")
                        .font(.largeTitle).bold()
                        .foregroundColor(.white)
                        .padding(.horizontal, 10)
                        .padding(.vertical, 5)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .background(LinearGradient(colors: [.clear, .black.opacity(0.7)], startPoint: .top, endPoint: .bottom))
                }
                .frame(width: .infinity)
                
                Text("\(scrollOffset)")
                
                VStack {
                    ForEach(0 ..< 30) { item in
                        RoundedRectangle(cornerRadius: 10)
                            .fill(.gray)
                            .frame(height: 100)
                            .padding(10)
                    }
                }
                .background(.white)
            }
            .background(GeometryReader { geometry in
                Color.clear
                    .preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
            })
            .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                self.scrollOffset = value.y
            }
        }
        .ignoresSafeArea()
    }
}

download.gif

スクロール量に応じて画像を暗くする

  • imageOverlayOpacityStateを作成
  • 画像の上に半透明のレイヤーを作成する(opacityにimageOverlayOpacityを指定する)
  • onPreferenceChangeイベント内で、imageOverlayOpacityの値を変化させる
  • 画像の高さは400なので、スクロール量が0の時opacityが0.0、スクロール量が400のときにopacityが1.0になるようにする
struct HogeView2: View {
    @State var imageOverlayOpacity: CGFloat = 0
    
    var body: some View {
        ScrollView {
            VStack(spacing: 0) {
                ZStack(alignment: .bottomLeading) {
                    Image("Wallpaper 1")
                        .resizable()
                        .frame(height: 400)
                        .frame(maxWidth: .infinity)
                    Text("Page Title")
                        .font(.largeTitle).bold()
                        .foregroundColor(.white)
                        .padding(.horizontal, 10)
                        .padding(.vertical, 5)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .background(LinearGradient(colors: [.clear, .black.opacity(0.7)], startPoint: .top, endPoint: .bottom))
                    Rectangle()
                        .fill(.black.opacity(imageOverlayOpacity))
                }
                .frame(width: .infinity)
                
                VStack {
                    ForEach(0 ..< 30) { item in
                        RoundedRectangle(cornerRadius: 10)
                            .fill(.gray)
                            .frame(height: 100)
                            .padding(10)
                    }
                }
                .background(.white)
            }
            .background(GeometryReader { geometry in
                Color.clear
                    .preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
            })
            .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                self.imageOverlayOpacity = -value.y / 400
            }
        }
        .ignoresSafeArea()
    }
}

download.gif

スクロール量に応じて画像をオフセットさせる

たとえば、100ポイントスクロールされたときに画像が50ポイント下に相対的にオフセットするようにすると、画像以外の要素は100ポイント上に進んだのに対して、画像は上に50ポイントしか進んでないというような移動量の差が作れる。
この移動量の差が視差効果になって、画像が少し奥にあるような立体的なインタラクションを実現する

struct HogeView2: View {
    @State var imageOverlayOpacity: CGFloat = 0
    @State var imageOffset: CGFloat = 0
    
    var body: some View {
        ScrollView {
            VStack(spacing: 0) {
                ZStack(alignment: .bottomLeading) {
                    Image("Wallpaper 1")
                        .resizable()
                        .frame(height: 400)
                        .frame(maxWidth: .infinity)
                        .offset(y: imageOffset)
                    Text("Page Title")
                        .font(.largeTitle).bold()
                        .foregroundColor(.white)
                        .padding(.horizontal, 10)
                        .padding(.vertical, 5)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .background(LinearGradient(colors: [.clear, .black.opacity(0.7)], startPoint: .top, endPoint: .bottom))
                    Rectangle()
                        .fill(.black.opacity(imageOverlayOpacity))
                }
                .frame(width: .infinity)
                
                VStack {
                    ForEach(0 ..< 30) { item in
                        RoundedRectangle(cornerRadius: 10)
                            .fill(.gray)
                            .frame(height: 100)
                            .padding(10)
                    }
                }
                .background(.white)
            }
            .background(GeometryReader { geometry in
                Color.clear
                    .preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
            })
            .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                self.imageOverlayOpacity = -value.y / 400
                self.imageOffset = -value.y / 5.0
            }
        }
        .ignoresSafeArea()
    }
}

download.gif

一定のスクロール量を超えたタイミングでナビゲーションバーを出現させる

  • NavigationStackの内側にGeometryReaderを配置することでsafeAreaを取得することができる
  • safeArea.topの値を使ってヘッダーっぽいRectangleを描画して、ZStackで上部に重ねて表示することで、お手製でヘッダーを再現できる。
struct HogeView2: View {
    @State var imageOverlayOpacity: CGFloat = 0
    @State var imageOffset: CGFloat = 0
    
    var body: some View {
        NavigationStack {
            GeometryReader { geometory in
                ZStack(alignment: .top) {
                    content
                    header(geometry: geometory)
                }
                .ignoresSafeArea()
            }
        }
    }
    
    func header(geometry: GeometryProxy) -> some View {
        ZStack {
            Rectangle()
                .fill(.ultraThinMaterial)
                .frame(height: geometry.safeAreaInsets.top + 40)
            VStack {
                Spacer().frame(height: geometry.safeAreaInsets.top)
                Text("Page Title")
                    .font(.title2)
                    .foregroundColor(.primary)
            }
        }
    }
    
    var content: some View {
        ScrollView {
            VStack(spacing: 0) {
                ZStack(alignment: .bottomLeading) {
                    Image("Wallpaper 1")
                        .resizable()
                        .frame(height: 400)
                        .frame(maxWidth: .infinity)
                        .offset(y: imageOffset)
                    Text("Page Title")
                        .font(.largeTitle).bold()
                        .foregroundColor(.white)
                        .padding(.horizontal, 10)
                        .padding(.vertical, 5)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .background(LinearGradient(colors: [.clear, .black.opacity(0.7)], startPoint: .top, endPoint: .bottom))
                    Rectangle()
                        .fill(.black.opacity(imageOverlayOpacity))
                }
                .frame(width: .infinity)
                
                VStack {
                    ForEach(0 ..< 30) { item in
                        RoundedRectangle(cornerRadius: 10)
                            .fill(.gray)
                            .frame(height: 100)
                            .padding(10)
                    }
                }
                .background(.white)
            }
            .background(GeometryReader { geometry in
                Color.clear
                    .preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
            })
            .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                self.imageOverlayOpacity = -value.y / 400
                self.imageOffset = -value.y / 5.0
            }
        }
    }
}

download.gif

  • あとは初期状態でナビゲーションバーのopacityを0にすることで見えなくしておいて、画像の高さ-ナビゲーションバーの高さの分がスクロールされた時にopacityを1にして出現させればOK
struct HogeView2: View {
    @State var imageOverlayOpacity: CGFloat = 0
    @State var imageOffset: CGFloat = 0
    @State var navigationBarOpacity: CGFloat = 0
    
    var body: some View {
        NavigationStack {
            GeometryReader { geometory in
                ZStack(alignment: .top) {
                    content(navigationBarHeight: geometory.safeAreaInsets.top + 40)
                    navigationBar(height: geometory.safeAreaInsets.top + 40)
                }
                .ignoresSafeArea()
            }
        }
    }
    
    func navigationBar(height: CGFloat) -> some View {
        ZStack {
            Rectangle()
                .fill(.ultraThinMaterial.opacity(navigationBarOpacity))
                .frame(height: height)
            VStack {
                Spacer().frame(height: height - 40)
                Text("Page Title")
                    .font(.title2)
                    .foregroundColor(.primary.opacity(navigationBarOpacity))
            }
        }
    }
    
    func content(navigationBarHeight: CGFloat) -> some View {
        ScrollView {
            VStack(spacing: 0) {
                ZStack(alignment: .bottomLeading) {
                    Image("Wallpaper 1")
                        .resizable()
                        .frame(height: 400)
                        .frame(maxWidth: .infinity)
                        .offset(y: imageOffset)
                    Text("Page Title")
                        .font(.largeTitle).bold()
                        .foregroundColor(.white)
                        .padding(.horizontal, 10)
                        .padding(.vertical, 5)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .background(LinearGradient(colors: [.clear, .black.opacity(0.7)], startPoint: .top, endPoint: .bottom))
                    Rectangle()
                        .fill(.black.opacity(imageOverlayOpacity))
                }
                .frame(width: .infinity)
                
                VStack {
                    ForEach(0 ..< 30) { item in
                        RoundedRectangle(cornerRadius: 10)
                            .fill(.gray)
                            .frame(height: 100)
                            .padding(10)
                    }
                }
                .background(.white)
            }
            .background(GeometryReader { geometry in
                Color.clear
                    .preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
            })
            .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                self.imageOverlayOpacity = -value.y / 400
                self.imageOffset = -value.y / 5.0
                self.navigationBarOpacity = CGFloat(Int(-value.y / (400 - navigationBarHeight)))
            }
        }
    }
}

download.gif

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1