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

# 完成イメージ
- 画面上部にでっかく画像を表示する（アルバムのアートワークとか）
- その画像の下の方にページタイトルを表示する（アルバム名とか）
- スクロールしていくと、だんだん画像が暗くなっていく
- 画像のスクロール量をページ自体のスクロール量よりも小さくすることで、画像が少し奥にあるような視差効果をつくる
- 画像の下部にあるページタイトルが画面から消えるぐらいのタイミングで、タイトルを画面上部に固定する
![download.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648522/10ef5f1a-c69b-00de-0619-4068aae39583.gif)

# 画像とタイトルとその下のListを作る
```swift
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648522/37eb13d9-39f2-453c-4b17-3b5872e14dbd.gif)


# スクロール量を取得する
- `ScrollOffsetPreferenceKey`を作成
- `ScrollView`の一個内側の要素の`background`に`GeometryReader`を配置し、さらにその内側に透明なpreferenceを設定した要素を配置する
- `onPreferenceChange`イベントでスクロール量を取得し、Stateを更新する
- Yスクロール量を保持するStateを作成し、とりあえずTextで表示してみる

```swift
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648522/d7cf77d7-995a-b028-9c43-02e42b6c723e.gif)

# スクロール量に応じて画像を暗くする
- `imageOverlayOpacity`Stateを作成
- 画像の上に半透明のレイヤーを作成する（opacityに`imageOverlayOpacity`を指定する）
- `onPreferenceChange`イベント内で、`imageOverlayOpacity`の値を変化させる
- 画像の高さは400なので、スクロール量が0の時opacityが0.0、スクロール量が400のときにopacityが1.0になるようにする

```swift
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648522/945df50f-2140-4bb4-eb21-fe18086b30ed.gif)

# スクロール量に応じて画像をオフセットさせる
たとえば、100ポイントスクロールされたときに画像が50ポイント下に相対的にオフセットするようにすると、画像以外の要素は100ポイント上に進んだのに対して、画像は上に50ポイントしか進んでないというような移動量の差が作れる。
この移動量の差が視差効果になって、画像が少し奥にあるような立体的なインタラクションを実現する
```swift
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648522/f81a8aff-07d3-31aa-f3b5-b96e7f752c2b.gif)

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

- `NavigationStack`の内側に`GeometryReader`を配置することで`safeArea`を取得することができる
- `safeArea.top`の値を使ってヘッダーっぽいRectangleを描画して、ZStackで上部に重ねて表示することで、お手製でヘッダーを再現できる。

```swift
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648522/d9be1539-26d2-d431-9e00-65c676961432.gif)

- あとは初期状態でナビゲーションバーのopacityを0にすることで見えなくしておいて、`画像の高さ-ナビゲーションバーの高さ`の分がスクロールされた時にopacityを1にして出現させればOK

```swift
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648522/ebb6df71-3bce-5940-aaec-27cf087ebc03.gif)