OSSを読んで調査しながら、手を動かしているうちに出来上がったのがこちらです。
はじめに
AirbnbのiOSアプリの実装に興味が沸いたのですが、Airbnbのソースを直接読むこともできません。そこで、似たような動作を実現しているOSSから内部実装を推測して自分でも書いてみることにしました。事前に調査したところ、既に似たようなことを考えている方がいて、大いに参考にさせて頂きました。感謝です。
今回の記事は、私が調査して気づいたことを再度整理した、という位置付けです。 これ以降の記述は下記のような読者を想定して書いていますので、ご承知おき下さい。 m(_ _)m
- iPhoneアプリ開発の経験は多少あるけれどもUIの実装は苦手
- 著名なアプリの実装に興味がある
画面の構成
TableViewが縦のスクロールを担い、各ジャンルのリストを横にスクロールしていく部分はTableViewの各rowの中に配置したCollectionViewで実現しています。
…日本語で表現しようとすると辛いのですが、要はこういう事です。
CollectionViewは縦横双方へのスクロールに対応しているので、一見、CollectionViewだけでも実現できそうな気もします。ただ、各ジャンルのリストは、それぞれ他ジャンルの影響を受けずに独立して横スクロールできる必要があるため、このような構成となっているようです。
スクロール位置に応じて拡大・縮小するヘッダ
Airbnbを使用していると、ユーザのスクロールに合わせて、ヘッダが縮小したり、拡大したりすることに気づくと思います。Facebookなどでも実装されているUIですね。
細かく見ていくと、少なくとも3つの機能を備えていることが確認できました。順番に見ていきます。
1.スクロール位置に応じてヘッダの高さが変化する
最初にアプリを起動したときには、ヘッダは目一杯の高さまでexpandして検索の利便性を高めています。ユーザがコンテンツを下にスクロールしていくと、徐々にヘッダは縮小されていきます。
2.ヘッダの高さに応じてalpha値を0から1の間で変化させる
(説明の都合上、ラベルの背景色を変更しています。)
現在のヘッダの高さに応じて、alpha値が0から1に変化しています。ヘッダの高さが最小のときalpha = 0, ヘッダの高さが最大の時alpha = 1になるように実装されています。
3.スクロールの途中、中途半端な位置で指を離すと、ヘッダの高さがmaximum height もしくは minimum heightまで拡大/縮小する
これも言葉でお伝えするのが難しいので、もう一度、実物をお見せします。
ユーザが途中でスクロールをストップし指を離した時に、ヘッダの高さをアニメーション付きで拡大 or 縮小させます。
つまり
- ヘッダの高さがmaximum heightよりminimum heightに近い状態で指を離したら、ヘッダの高さはminimum heightまで縮小します。
- ヘッダの高さがminimum heightよりmaximium heightに近い状態で指を離したら、ヘッダの高さはmaximum heightまで拡大します。
以下、本文中ではこの機能を snap
と呼ぶことにします。
実装
さきほど説明した3つの機能の内部実装をみていきます。
first step
ヘッダの高さを制御するために、ヘッダの height constraintをIBOutletとして切り出します。
SearchExpandableHeader.swift
class SearchExpandableHeader: UIView {
@IBOutlet fileprivate weak var _heightConstraint: NSLayoutConstraint!
ヘッダの高さ(minimum height, maximum height)を定義する
ヘッダの高さはコンテンツのスクロールに応じて一定の範囲内で変動します。 これを実現するために、minimum height, maximum heightを定義します。
SearchExpandableHeader.swift
private var _maximumHeight: CGFloat = 140
private var _minimumHeight: CGFloat = 90
スクロールの方向に応じて動きを変える
スクロールの方向を判定するために、previousContentOffset
と scrollView.contentOffset.y
を比較しています。
SearchViewcontroller.swift
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let deltaYOffset = scrollView.contentOffset.y - previousContentOffset
let isScrollingDown = deltaYOffset > 0 && !isTopReached(scrollView)
let isScrollingUp = deltaYOffset < 0 && !isBottomReached(scrollView)
if expandableHeader.canAnimate(scrollView) {
let newHeight = nextHeight(isScrollingDown: isScrollingDown, isScrollingUp: isScrollingUp, deltaYOffset: deltaYOffset, currentHeight: expandableHeader.heightConstraintConstant)
if newHeight != expandableHeader.heightConstraintConstant {
expandableHeader.heightConstraintConstant = newHeight
setScrollPosition(position: previousContentOffset)
}
}
}
- 前回のスクロール位置との差分
- スクロールの方向
が得られたので、ヘッダの高さを変更できます。スクロールの差分に応じてヘッダの高さを変動させます。このとき、あらかじめ定義した minimum heightからmaximum heightの範囲内におさまるように、max, minを用いて制御しています。
SearchViewcontroller.swift
private func nextHeight(isScrollingDown: Bool, isScrollingUp: Bool, deltaYOffset: CGFloat, currentHeight: CGFloat) -> CGFloat {
if isScrollingDown {
return max(expandableHeader.minimumHeight, expandableHeader.heightConstraintConstant - abs(deltaYOffset))
} else if isScrollingUp {
return min(expandableHeader.maximumHeight, expandableHeader.heightConstraintConstant + abs(deltaYOffset))
}
// スクロールしてなければ、もとの高さのまま
return currentHeight
}
tableviewに含まれるコンテンツが少なくて、画面におさまる範囲内であれば、ヘッダの高さを変化させる必要性もありません。そこで事前に canAnimate(scrollView)
を実行してコンテンツの量をチェックしています。考慮漏れしそうな所ですが、この辺の気遣いは素晴らしいなーと思いました。
SearchViewcontroller.swift
if expandableHeader.canAnimate(scrollView) {
// ヘッダの高さを計算する
}
canAnimateの中身はこんな感じ。。
SearchViewcontroller.swift
func canAnimate(_ scrollView: UIScrollView) -> Bool {
let scrollViewMaxHeight = scrollView.frame.height + range
return scrollView.contentSize.height > scrollViewMaxHeight
}
ヘッダの高さに応じてalpha値を変化させる
ヘッダのheightの制約を更新すると、updateProgress()
が実行され、どの程度ヘッダが拡大/縮小しているのかを計算し、_progress
に格納します。
_progressは割合を表すので、0から1の範囲におさまるように制御しています。
SearchExpandableHeader.swift
public var heightConstraintConstant: CGFloat {
get {
return _heightConstraint.constant
}
set (newHeightConstraintConstant) {
_heightConstraint.constant = newHeightConstraintConstant
updateProgress()
}
}
private func updateProgress() {
let openAmount = _heightConstraint.constant - minimumHeight
let newProgress = openAmount / range
_progress = fmin(fmax(newProgress, 0.0), 1.0)
}
制約が更新されるとlayoutSubviews()
が呼び出され、さきほど計算した _progress
の値を用いて、ヘッダのalphaを操作します。
SearchExpandableHeader.swift
override func layoutSubviews() {
super.layoutSubviews()
UIView.animate(withDuration: 0.2, animations: {
self.searchConditionsView.alpha = self._progress
})
}
スクロールが止まったらsnapを効かせていい感じにヘッダを動かす
スクロールが止まったタイミング/指を離した後にスクロールしなくなったタイミングを検知して、snap()
メソッドを呼び出します。
SearchViewController.swift
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// scrolling has stopped
expandableHeader.snap()
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate {
// scrolling has stopped
expandableHeader.snap()
}
}
上の図の赤い縦線の範囲内でヘッダの高さが変化するのですが、snap()
が発動した時点で、ヘッダの高さがmidPointを超えていれば、ヘッダが拡大します。ヘッダの高さがmidPointよりも小さければ、ヘッダが縮小します。
SearchExpandableHeader.swift
func snap() {
let midPoint = minimumHeight + (range / 2)
if heightConstraintConstant > midPoint {
expand()
} else {
collapse()
}
}
expand()
, collapse()
はこんな感じで実装しました。
制約を変更する前に、未適用の表示を更新しておきます。
そのあと、animationブロックの外で制約を変更して、アニメーションを実行します。
SearchExpandableHeader.swift
protocol SearchExpandableHeaderDelegate {
func updateLayout()
}
class SearchExpandableHeader: UIView {
private func expand() {
self.delegate?.updateLayout()
self.heightConstraintConstant = self.maximumHeight
UIView.animate(withDuration: 0.6, animations: {
self.delegate?.updateLayout()
})
}
private func collapse() {
self.delegate?.updateLayout()
self.heightConstraintConstant = self.minimumHeight
UIView.animate(withDuration: 0.6, animations: {
self.delegate?.updateLayout()
})
}
}
SearchViewController.swift
extension SearchViewController: SearchExpandableHeaderDelegate {
func updateLayout() {
self.view.layoutIfNeeded()
}
}
以上で完成です。
最後に
誤りの指摘などございましたら、お気軽にコメントをお願い致します m(_ _)m
一瞬の動きの中にも、ソースを読まないと気づかないような細かい制御がいくつも入っていて勉強になりました。
今回、作成したAirbnb cloneのソースコードはこちらに置きました。また時間をみつけて、他の画面も掘り下げてみたいと思います。
あわせて読みたい
このあたりは実際のAppStoreにリリースされているものがOSSになっているようで、面白そうですね。
- kickstarter/ios-oss: Kickstarter for iOS. Bring new ideas to life, anywhere.
- RocketChat/Rocket.Chat.iOS: Rocket.Chat Native iOS Application
- voisine/breadwallet-ios: bread - bitcoin wallet
参考リンク
実装にあたっては、下記を参考にさせていただきました。
- UX Analysis: How Airbnb Engages Users w/ Fictionless Flows | Leanplum
- yonasstephen/swift-of-airbnb: A self-taught project of learning Swift by making some of Airbnb’s screens
- BLKFlexibleHeightBar/SquareCashStyleBehaviorDefiner.m at cbb6bda8d3c55d465db6b77dd01b8107dcff8a08 · bryankeller/BLKFlexibleHeightBar
- Putting a UICollectionView in a UITableViewCell in Swift - Ash Furrow
- iOS Animating UITableView Header - Michigan Software Labs
- kicksterter/ios-oss を観察してみて思ったこと(その1) - おぼえがき
- UIViewにおけるレイアウトのライフサイクル - Qiita
- iOSエンジニア必見!!iOSのレイアウトで押さえておきたいこと【総集編】 | eureka tech blog
- AutoLayout制約値を変更したアニメーションで勘違いしていた件 - Qiita