スマホアプリは小さな画面を有効活用するため、必要のない時はメニューを隠しておくと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 を入れる必要がある
コメントを残す