[SwiftUI] スクロールに応じて特定のViewを下げたり上げたりしたい

## はじめに
こんにちは!
アプリ開発が好きで、Swiftの勉強をしている大学生です。
温かい目で見ていただけると幸いです。


## やりたいこと
下記のような特定のViewに対して、スクロールに応じてViewをあげたり下げたりするようにしたいです。
個人開発ではCustomTabBarに対して使ってました。(※Twitterでの評判があまり良くなく、廃止しました)

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3119753/03133e4e-7026-1df5-6312-454255de263f.gif" width="250">

## 実装イメージ
1. ScrollViewのoffsetを取得する
1. 取得したoffsetの値に応じてViewを下げるか上げるかを判断する
1. それをView側に通知して、offsetを下げたり上げたりする。

このような感じのイメージをを持ちながら、実装していきます。

## 実装
まず[こちら](https://qiita.com/hcrane/items/3f3a9ff2ef0fccd01338)の記事を参考に、ScrollViewの座標を取得します。

### PreferenceKeyの設定

```Swift
public struct ScrollViewOffsetPreferenceKey: PreferenceKey {
    public typealias Value = CGFloat

    public static var defaultValue = CGFloat.zero

    public static func reduce(value: inout Value, nextValue: () -> Value) {
        value += nextValue()
    }
}
```

ScrollViewをラップして、独自のScrollViewを作ります。

```Swift
public struct ScrollViewOffset<V: View>: View {
    private let coordinateSpaceName = "scrollViewSpaceName"

    @State private var oldOffset: CGFloat = 0
    @State private var scrollState: ScrollDirection = .up

    private let content: V

    public init(@ViewBuilder content: () -> V) {
        self.content = content()
    }

    public var body: some View {
        ScrollView {
            content
                .background(
                    GeometryReader { proxy in
                        let offset = proxy.frame(in: .named(coordinateSpaceName)).minY
                        Color.clear.preference(key: ScrollViewOffsetPreferenceKey.self, value: offset)
                    }
                )
        }
        .coordinateSpace(name: coordinateSpaceName)
        .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
            if value >= 0 {
                isUpDown(for: .up)
            } else if value > oldOffset {
                isUpDown(for: .up)
            } else {
                isUpDown(for: .down)
            }
            oldOffset = value
        }
    }
    
    //  もっといい命名絶対ある
    private func isUpDown(for scrollState: ScrollDirection) {
        switch scrollState {
        case .up:
            NotificationCenter.default.post(name: .up, object: nil)
        case .down:
            NotificationCenter.default.post(name: .down, object: nil)
        }
    }
}

```
今回は`NotificationCenter`を使って、`onPreferenceChange`で受け取った`value`に応じてViewを上げるか下げるかを通知します。
`Notificaion`の`name`はわかりやすいように下記のような`extension`を定義してあります。

```swift
public extension Notification.Name {
    static let up = Self("up")
    static let down = Self("down")
}
```

以上で下準備は完了です。
上記の利用方法は以下のとおりです。

```swift
public struct HogeView: View {
    
    @State private var isUP = false
    
    public var body: some View {
        ScrollViewOffset {
            VStack {
                Text("これ下げます☟")
                
                Circle()
                    .foregroundColor(.clear)
                    .frame(width: 200, height: 200)
                    .background(HogeBackGround())
                    .offset(y: isUP ? 0 : 200)
                    .onReceive(NotificationCenter.default.publisher(for: .up)) { _ in
                        isUP = true
                    }
                    .onReceive(NotificationCenter.default.publisher(for: .down)) { _ in
                        isUP = false
                    }
            }
        }
    }
}

```

自作したScrollViewを使い、`onReceive`で検知します。
あとはViewを上げるか下げるかどうかのフラグを変数で保持し、それに応じて直接offsetを好きな値に変更することで実装できました。
今回はViewを下げるだけでしたが、上げることもできますのでぜひ試してみてください。


### Tips

`onReceive`の引数に記述している`Notification`が少し冗長なので、下記のような`extension`を定義することでみやすくなります

```swift
public extension View {
    func onReceive(_ name: Notification.Name, perform: @escaping () -> Void) -> some View {
        onReceive(NotificationCenter.default.publisher(for: name), perform: { _ in perform() })
    }
}
```

また、この実装にあたってかなり勉強になった記事です🙇

https://qiita.com/maiyama18/items/a64bab2094750319ef8d

https://developer.apple.com/documentation/swiftui/view/coordinatespace(name:)

https://qiita.com/hcrane/items/3f3a9ff2ef0fccd01338


## 終わりに
誰かの役に立つことができていれば幸いです。
アウトプットを頑張ろうと思っているので温かい目で見ていただけると嬉しいです。