blog

SwiftUIでスクロールに応じてメニューを隠す処理を実装してみた

スマホアプリは小さな画面を有効活用するため、必要のない時はメニューを隠しておくとUX 向上につながります。
メニューを常時表示する方が実装は簡単ですが、今回はスクロールに応じて表示・非表示を切り替えるUIを実装してみました。


スクロール位置の取得

Swift でスクロール位置を取得するには PreferenceKey を使用します。これは、スクロールする View と位置を受け取りたい親 View 間で値を連携する手段です。
今回は、LazyVStack のスクロール位置を取得して、ContentsView で位置を受け取りメニュー表示の判断を行います。
私はjavascript のように、window.scrollY で取得できるだろうと安易に考えていましたが、それよりは少し複雑でした。

スクロールするView

var body: some View {
    ScrollView {
        LazyVStack(alignment: .leading, spacing: 0) {
            // 大量のコンテンツ(省略)
        }
        // padding については後述
        .padding(.top, topMenuHeight)
        .padding(.bottom, bottomMenuHeight)
        .background(
            GeometryReader {
                Color.clear.preference(
                    key: ScrollOffsetPreferenceKey.self,
                    // 画面上部のスクロール位置を取得してPreferenceKey で連携
                    value: $0.frame(in: .scrollView).minY
                )
            }
        )
    }
}

Color.clear は画面に何も表示しないです。.preference を設定するために使用しています

位置を取得するContentsView

var body: some View {
    GeometryReader { geo in
        ZStack(alignment: .top) {
            // 画面コンテンツ(省略)
        }       .onPreferenceChange(ScrollOffsetPreferenceKey.self) { newY in
            // スクロール発生時にnewY が連携される
            refreshMenu(newY: newY, geo: geo)
        }
    }
}

PreferenceKey の実装(お作法に従ったもの)

struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value += nextValue()
    }
}

Color.clear.preference から.onPreferenceChange へスクロール位置が連携されます。


メニュー表示判定

判定自体は比較的簡単です。
スクロール位置は1フレーム毎に送付されるので、前回のスクロール位置を記憶しておくことで差分を算出できます。
私は差分を15(ポイント)で判断するのが良いと思いました。
そのため、+15 以上であれば下向きスクロールなのでメニューを隠す、-15 以下であれば、上向スクロールなのでメニューを表示する、これが基本になります。

加えて、スクロールの上部に来た時はメニューが常に表示された方が良いです。
スクロール位置が10 以下の場合は差分に関係なく表示するようにしました。

private func calcMenuVisibilityAction(_ newY: CGFloat, _ oldY: CGFloat) -> MenuVisibilityActionEnum {
    let threshold: CGFloat = 15
    let topArea: CGFloat = 10
    if topArea < newY || topArea < oldY {
    // 上部に近い場合は常にメニューを表示
        return .show
    }
    let delta = newY - oldY
    if threshold < delta {
        // 上スクロール:メニューを表示
        return .show
    } else if threshold < -delta {
        // 下スクロール:メニューを隠す
        return .hide
    }
    // その他は何もしない
    return .none
}

メニュー位置の算出

上のメニューについて考えてみます。
表示するときは、上メニューのY座標を0 にすることで表示されます。こちらは簡単です。
隠す時は少し厄介で、iPhone では、画面上部のベゼル部分(ノッチなど)を考慮する必要があります。
メニューの高さにベゼルの高さを加えた分を画面外へ移動させます。

ベゼルの高さはGeometoryReader のsafeAreaInsets.top で取得できます。
画面外へのオフセットはこのようなコードで取得できます。(topMenuHeight は固定値)
topMenuOffset = 0 – geo.safeAreaInsets.top – topMenuHeight

下のメニューも同様にベゼルを考慮しつつ、画面外に移動させる量を算出します。


スクロールView に余白を追加

LazyVStack に.padding で上下メニューの高さを追加します。
これがないと、メニューの裏側にコンテンツが隠れてしまいます。

ただし、一番下までスクロールした際に、.padding(.bottom) で追加した余白がそのまま表示されてしまいます。
見た目的に少し間延びする印象があるのですが、完全に解決するのは難しく、今回は割り切ってそのままにしています。
ちなみに、GmailのiOSアプリでも同じような余白が見られたため、それほど大きな問題にはならないと判断しました。


まとめ

  • PreferenceKey を使ってスクロール座標を取得する
  • 前回のスクロール位置と比較することで、メニュー表示の判定が可能
  • ベゼルを考慮してメニューを隠すときの表示位置を算出
  • メニューが重ならないように padding を入れる必要がある

ソース

【広告】

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です