[iOS] UICollectionView のスクロールに合わせてヘッダーの高さが変わる没入感のあるUIを作る

こういうUIを作りたい

iPhone11 の場合。

iPhone11 の場合

iPhone8 の場合。

iPhone8 の場合

(iPhone11 の動画が無駄な余白でめっちゃ縦長に見えるんだが。。😇)

スクロールに合わせてヘッダー部分の領域の高さも可変にしたいです!
ということで、それのやり方をまとめました。

まずはCollectionView 部分を作る

これはすぐにできます。

本ブログの [iOS] UIViewController の中に UICollectionView を貼り付ける の記事で書いた方法で、
UIViewController の中に UICollectionView を貼り付ける形で作ります。
UICollectionView は上下左右いっぱいに制約をつけます。

その際、CollectionView の上下の制約の付け方に注意です。
上下の制約を、SafeArea ではなく、親のView(= UIViewController のView) につけます。
図示するとこんな感じ。

上下の制約を親のView につける

また、UIViewController の背景色は黒にしています。

すると、以下動画のようになります。

Xcode11の場合。

iPhone11の場合

iPhone8の場合。

iPhone8 の場合

特に細かい位置調整をしていませんでしたが、iPhone11 の場合とiPhone8 の場合どちらも
一番上の開始位置がステータスバーの直下になるよう自動的に調整されます

これは viewWillAppear と viewDidAppear の間の処理で、
自動でスクロール位置(= collectionView.contentOffset.y) の調整がなされているためで、
iPhone11の場合は-48.0、iPhone8 の場合は-20.0 でした。

また、safeAreaInsetstop は iPhone11の場合は48.0、iPhone8 の場合は20.0 でした。

つまり、ステータスバーに被らないように、その高さ分最初から
CollectionView の位置がマイナスに調整されているという挙動になります。

ヘッダー部分を作る

固定の高さを持つヘッダー部分を作成

以下の画像のように、親のView の上に UIView を貼り付けます。
Z軸でCollectionView より上に持っていきたいので、CollectionView より後に加えます。
背景色は半透明の白です。

ヘッダー部分のUIView を貼り付ける

ここで、ヘッダー部分の高さを、ステータスバーを除いた部分を100にしたいとします。
要は以下の画像の感じ。

画像に書いている通りステータスバーの高さは端末によって異なってきます。
そのため、「ヘッダー部分の高さ = 100 + ステータスバーの高さ」というように動的に計算する必要があります。

という訳で、ステータスバーの高さを取得します。
以下のコードで取得できます。
出し分けロジックがあるとコードがごちゃつくので、メソッドに切り出してみました。

func statusBarHeight() -> CGFloat? {
    if #available(iOS 13.0, *) {
        let window = UIApplication.shared.windows.filter {$0.isKeyWindow}.first
        return window?.windowScene?.statusBarManager?.statusBarFrame.height
    } else {
        return UIApplication.shared.statusBarFrame.height
    }
} 

これでステータスバーの高さを動的に取得できたので、
あとはヘッダービューの高さの制約をIBOutlet で繋ぎ、
constant に値を代入してやればOKです!

IBOutlet で繋ぎ。

@IBOutlet weak var headerViewConstraintHeight: NSLayoutConstraint! 

viewWillAppear もしくは viewDidAppear で高さを設定する。
(VCの表示タイミングによって最適な記述場所は変わってきそう)

if let statusBarHeight = statusBarHeight() {
    headerViewConstraintHeight.constant = statusBarHeight + 100.0
} 

で、以下動画の感じになりました!

iPhone11 の場合。

iPhone 11の場合

iPhone8 の場合。

iPhone8の場合

セルの上部がヘッダー部分に被ってアレなので、セルの表示開始位置を少し下げます。

セルの開始位置を下げる

こちらは ViewDidLoad に記述します。

additionalSafeAreaInsets = UIEdgeInsets(
    top: 100.0,
    left: 0,
    bottom: 0,
    right: 0
) 

こうするとSafeArea が領域が加算されます。
CollectionView は SafeArea の分、既に位置調整がなされているので(iPhone11 なら48.0)、
それに更に100(つまりヘッダー部分からSafeArea の値を引いた高さ)を加算しました。

こんな感じになります!

iPhone11 の場合。

iPhone11 の場合

iPhone8 の場合。

iPhone8 の場合

ヘッダー部分の高さを動的に変える

スクロールに合わせてヘッダー部分の高さを変えます。
以下のような方針でいきます。

  1. CollectionView のスクロールに合わせてヘッダー部分の高さも変える
  2. ヘッダーの高さの最小値は SafeArea の高さ、最大値は SafeArea + 100 の高さ
  3. スクロール領域を超える位置でのスクロールはヘッダーの高さを変えない

1.CollectionView のスクロールに合わせてヘッダー部分の高さも変える

下へのスクロールか上へのスクロールかをしたかを判定したいので、
直前のスクロール位置との比較をすることで実現したいと思います。

という訳でViewController のプロパティに以下を用意します。

private var previousContentOffsetY: CGFloat = 0.0 

で、今回はVC表示時にSafeAreaInsets が自動調整されている & additionalSafeAreaInsets で更にいじっているので、
その調整が終わったあとに初期値を設定します。
ということで viewDidAppear で以下のように設定します。

previousContentOffsetY = -(statusBarHeight() ?? 0.0 + 100.0 )

なぜ viewDidAppear か。
viewWillAppear と viewDidAppear の間にSafeArea を考慮した位置調整ロジックが自動で入り、
そこで一度 scrollViewDidScroll が呼ばれます。
なので、そのロジックが終わって初期表示が終わったタイミングできちんと設定しています。

あとはこの previousContentOffsetY プロパティを使って、
デリゲートメソッドである scrollViewDidScroll(_:) にてヘッダーの高さを調節します。

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let height = headerViewConstraintHeight.constant - (scrollView.contentOffset.y - previousContentOffsetY)
    
    headerViewConstraintHeight.constant = height
    previousContentOffsetY = scrollView.contentOffset.y
} 

これでスクロールに合わせてヘッダーの高さも増減するようになりました。

が、無制限に増減するので、
最小の高さ(SafeArea の高さ)から最大の高さ(SafeArea + 100 の高さ)の範囲になるよう調整します。

2.ヘッダーの高さの最小値は SafeArea の高さ、最大値は SafeArea + 100 の高さ

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let height = headerViewConstraintHeight.constant - (scrollView.contentOffset.y - previousContentOffsetY)
    let minHeight = statusBarHeight() ?? 0.0
    let maxHeight = (statusBarHeight() ?? 0.0) + 100.0
    
    headerViewConstraintHeight.constant = min(max(minHeight, height), maxHeight)
    previousContentOffsetY = scrollView.contentOffset.y
} 

以下動画の動きになりました。
ボヨンボヨンして変ですね!

iPhone11 の場合。

iPhone11 の場合

iPhone8 の場合。

iPhone8 の場合

3.スクロール領域を超える位置でのスクロールはヘッダーの高さを変えない

一番上から更に上にいこうとしたとき。
初期位置のcontentOffset よりも更にマイナス方向にいこうとしているのでこれを防ぎます。

if scrollView.contentOffset.y < -((statusBarHeight() ?? 0.0) + 100.0) {
    return
}

一番下から更に下にいこうとしたとき。
CollectionView の コンテンツ部分(ScrollView) の高さよりも更にプラスにいこうとしているのでこれを防ぎます。

if scrollView.contentOffset.y + collectionView.bounds.height - view.safeAreaInsets.bottom > scrollView.contentSize.height {
    return
} 

こっちの計算式はややこしいので図にすると以下のイメージです。

で、これを動かしてみると以下のようになります。
iPhone8は若干被りが見えてるので、もしかしたら微調整は必要かもですが、だいぶいい感じですね!

iPhone11 の場合。

iPhone11 の場合

iPhone8 の場合。

iPhone8 の場合

これで完成です!

最後に

スクロール位置計算はけっこうややこしくて、ぱっと見何してるか分からなくなりそうなので、
変数名を工夫するなどして可読性を上げた方がよさそうだなと思いました。

以上です。お疲れ様でした!

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Google フォト

Google アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中

%d人のブロガーが「いいね」をつけました。