はじめに
この記事はand factory Advent Calendar 2020 の6日目の記事です。
昨日は@myoshitaさんのNavigation Component with Kotlin DSLでした!
今回は、iOSでスクロール時にNavigationBarとTabBarを隠したり表示したりする機能を自作してみましたので
実装の仕方をまとめてみようと思います。
環境
開発環境は以下の通りです。
Tool | Version |
---|---|
Xcode | 12.2 |
Swift | 5.3.1 |
Target |
---|
iOS 13 or later |
Interface |
---|
Storyboard (UIKit) |
※ 本投稿ではSwiftUI版の内容は含んでおりませんm(_ _)m
完成イメージ
スクロール方向に応じて、Barを隠したり表示したりします
dragging | scrolling |
---|---|
実装の方針について
スクロールに応じてNavigationBarを隠したり表示したりする機能はいくつかOSSがあったのですが、
Barを隠した分TableViewをうまくリサイズできなかったりしたので
勉強も兼ねてスクラッチで実装してみようと思い、やってみました!
※ OSSに関しては、自分が実装の仕方誤っていただけかもなので、本記事では特に扱いませんm(_ _)m
実装の流れ
大まかには以下のような実装になります。
スクロール開始時にスクロール位置を保持
↓
スクロール量に応じて、NavigationBarを上に、TabBarを下にそれぞれ移動させる
↓
ScrollView(サンプルではTableView)のTopとBottomのConstraintを更新する
AutoLayoutの設定
特に意識せずにTableViewのAutoLayoutを設定するとSafeAreaに対して上下の制約を設定してしまいますが
今回は、Superviewに対して制約を設定します。
設定した制約をソースコードに紐付けて、制約を制御することで
NavigationBarとTabBarが表示されている場合は、Barの高さ分だけ制約でスペースを開けて
NavigationBarとTabBarが隠されている場合は、画面の端までTableViewを広げられるようになります。
実装
複数の画面で同じ実装をすることになりそうだったので、
HideBarManagerというクラスを作って、ViewControllerから必要なプロパティを渡して
スクロール量の計算や制約の更新をします(*・ω・*)
初期処理
final class HideBarManager {
/// スクロールビューのTopの制約
///
/// - Note: Topの制約は、SafeAreaInsets.TopではなくSuperview.Topに対して設定してください
private weak var topConstraint: NSLayoutConstraint?
/// スクロールビューのBottomの制約
///
/// - Note: Bottomの制約は、SafeAreaInsets.BottomではなくSuperview.Bottomに対して設定してください
private weak var bottomConstraint: NSLayoutConstraint?
/// スクロールビュー
private weak var scrollView: UIScrollView!
/// タブバー
private weak var tabBar: UITabBar?
/// ナビゲーションバー
private weak var navigationBar: UINavigationBar?
/// タブバーのフレーム
private var tabBarDefaultFrame: CGRect!
/// ナビゲーションバーのフレーム
private var navigationBarDefaultFrame: CGRect!
/// スクロール開始時の位置
private var scrollBeginningOffsetY: CGFloat?
/// タブバーの移動量
private var tabBarMovingDistance: CGFloat = 0
/// ナビゲーションバーの移動量
private var navigationBarMovingDistance: CGFloat = 0
/// スクロール方向
private var scrollDirectionY = UIScrollView.ScrollDirectionY.none
private init() {}
static func build(topConstraint: NSLayoutConstraint?,
bottomConstraint: NSLayoutConstraint?,
scrollView: UIScrollView!,
tabBar: UITabBar?,
navigationBar: UINavigationBar?) -> HideBarManager {
let hideBarManager = HideBarManager()
hideBarManager.topConstraint = topConstraint
hideBarManager.bottomConstraint = bottomConstraint
hideBarManager.scrollView = scrollView
hideBarManager.tabBar = tabBar
hideBarManager.navigationBar = navigationBar
return hideBarManager
}
}
VC側
private lazy var hideBarManager = HideBarManager.build(
topConstraint: self.tableViewTopConstraint,
bottomConstraint: self.tableViewBottomConstraint,
scrollView: self.tableView,
tabBar: self.tabBarController?.tabBar,
navigationBar: self.navigationController?.navigationBar
)
ライフサイクル処理
ViewControllerの各ライフサイクルのメソッドが呼び出されるタイミングで実行する処理をHideBarManagerに実装していきます。
extension HideBarManager {
func viewDidLoad() {
self.addObservers()
}
func viewDidLayoutSubviews() {
// 初回のみ代入したいためnilチェックもする
if let tabBar = self.tabBar {
self.tabBarDefaultFrame = self.tabBarDefaultFrame ?? tabBar.frame
}
if let navigationBar = self.navigationBar {
self.navigationBarDefaultFrame = self.navigationBarDefaultFrame ?? navigationBar.frame
}
self.updateConstraints()
}
func viewWillDisappear() {
self.showBarImmediately()
}
}
extension HideBarManager {
private func addObservers() {
// アプリ復帰時にナビゲーションバーを表示しておくために、非アクティブになる直前に表示しておく
NotificationCenter.default.addObserver(self,
selector: #selector(showBarImmediately),
name: UIApplication.willResignActiveNotification,
object: nil)
}
/// タブバーとナビゲーションバーを表示させる
@objc private func showBarImmediately() {
self.tabBar?.frame = self.tabBarDefaultFrame
self.navigationBar?.frame = self.navigationBarDefaultFrame
self.tabBarMovingDistance = 0
self.navigationBarMovingDistance = 0
self.updateConstraints()
}
/// TopとBottomの制約を更新する
private func updateConstraints() {
guard let superview = self.scrollView.superview else {
return
}
if self.tabBar != nil {
self.bottomConstraint?.constant = -superview.safeAreaInsets.bottom + self.tabBarMovingDistance
}
if self.navigationBar != nil {
self.topConstraint?.constant = superview.safeAreaInsets.top + self.navigationBarMovingDistance
}
}
}
VC側
override func viewDidLoad() {
super.viewDidLoad()
self.hideBarManager.viewDidLoad()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
self.hideBarManager.viewDidLayoutSubviews()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
self.hideBarManager.viewWillDisappear()
}
UIScrollViewDelegateの処理
UIScrollViewDelegateの各メソッドが呼び出されるタイミングで実行する処理をHideBarManagerに実装していきます。
extension HideBarManager {
func scrollViewShouldScrollToTop() {
self.showBarImmediately()
}
/// ドラッグ開始
func scrollViewWillBeginDragging() {
if self.scrollBeginningOffsetY == nil {
self.scrollBeginningOffsetY = self.scrollView.contentOffset.y
self.tabBarMovingDistance = 0
self.navigationBarMovingDistance = 0
}
}
/// ドラッグ終了
func scrollViewDidEndDragging(decelerate: Bool) {
// 慣性によるスクロールがない場合は、スクロール開始位置とスクロール方向を初期化する
if !decelerate {
self.scrollBeginningOffsetY = nil
self.scrollDirectionY = .none
}
}
/// スクロール中
func scrollViewDidScroll() {
guard self.scrollView.isDragging else {
return
}
let scrollViewHeight = scrollView.frame.size.height
let scrollContentSizeHeight = scrollView.contentSize.height
let scrollOffset = scrollView.contentOffset.y
let scrollBeginningOffset = self.scrollBeginningOffsetY ?? 0
// 一番上に到達した場合は、Barを表示する
if scrollOffset <= 0 {
self.showBarImmediately()
return
}
// 一番下に到達した場合は、何もしない
if scrollContentSizeHeight <= scrollOffset + scrollViewHeight {
return
}
// 下へスクロールしていて、
// スクロール方向をまだ保持していない or 保持しているスクロール方向がbottomの場合
// ナビゲーションバーとタブバーを移動させる
if scrollBeginningOffset < scrollOffset {
if self.scrollDirectionY == .none || self.scrollDirectionY == .bottom {
self.scrollDirectionY = .bottom
self.moveTabBarAndNavigationBar()
return
}
}
// 上へスクロールしていて、
// スクロール方向をまだ保持していない or 保持しているスクロール方向がtopの場合
// ナビゲーションバーとタブバーを移動させる
if scrollBeginningOffset > scrollOffset {
if self.scrollDirectionY == .none || self.scrollDirectionY == .top {
self.scrollDirectionY = .top
self.moveTabBarAndNavigationBar()
return
}
}
}
/// スクロール停止
func scrollViewDidEndDecelerating() {
self.scrollBeginningOffsetY = nil
self.scrollDirectionY = .none
}
}
extension HideBarManager {
/// タブバーとナビゲーションバーを移動させる
private func moveTabBarAndNavigationBar() {
guard
let tabBarFrame = self.calcTabBarFrame(),
let navigationBarFrame = self.calcNavigationBarFrame() else {
return
}
self.tabBar?.frame = tabBarFrame
self.navigationBar?.frame = navigationBarFrame
self.tabBarMovingDistance = tabBarFrame.origin.y - self.tabBarDefaultFrame.origin.y
self.navigationBarMovingDistance = navigationBarFrame.origin.y - self.navigationBarDefaultFrame.origin.y
self.updateConstraints()
}
/// スクロール量からタブバーのoffsetを算出し、タブバーのframeを返す
private func calcTabBarFrame() -> CGRect? {
guard let scrollBeginningOffsetY = self.scrollBeginningOffsetY else {
return nil
}
// 移動量
let movingDistance = self.scrollView.contentOffset.y - scrollBeginningOffsetY
var newTabBarOriginY = self.tabBarDefaultFrame.origin.y + movingDistance
// もとの位置より上には移動させない
let min = self.tabBarDefaultFrame.origin.y
if newTabBarOriginY < min {
newTabBarOriginY = min
}
// 画面外に出たらそれ以上は下に移動させない
let max = self.tabBarDefaultFrame.origin.y + (self.tabBarDefaultFrame.size.height * 2)
if newTabBarOriginY > max {
newTabBarOriginY = max
}
var newTabBarFrame = self.tabBarDefaultFrame!
newTabBarFrame.origin.y = newTabBarOriginY
return newTabBarFrame
}
/// スクロール量からナビゲーションバーのoffsetを算出し、ナビゲーションバーのframeを返す
private func calcNavigationBarFrame() -> CGRect? {
guard let scrollBeginningOffsetY = self.scrollBeginningOffsetY else {
return nil
}
// 移動量
let movingDistance = self.scrollView.contentOffset.y - scrollBeginningOffsetY
var newNavigationBarOriginY = self.navigationBarDefaultFrame.origin.y - movingDistance
let statusBarHeight = self.scrollView.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
// 画面外に出たらそれ以上は上に移動させない
let min = self.navigationBarDefaultFrame.origin.y - (self.navigationBarDefaultFrame.size.height * 2) - statusBarHeight
if newNavigationBarOriginY < min {
newNavigationBarOriginY = min
}
// もとの位置より下には移動させない
let max = self.navigationBarDefaultFrame.origin.y
if newNavigationBarOriginY > self.navigationBarDefaultFrame.origin.y {
newNavigationBarOriginY = max
}
var newNavigationBarFrame = self.navigationBarDefaultFrame!
newNavigationBarFrame.origin.y = newNavigationBarOriginY
return newNavigationBarFrame
}
}
VC側
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
self.hideBarManager.scrollViewShouldScrollToTop()
return true
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
self.hideBarManager.scrollViewWillBeginDragging()
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
self.hideBarManager.scrollViewDidEndDragging(decelerate: decelerate)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
self.hideBarManager.scrollViewDidScroll()
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
self.hideBarManager.scrollViewDidEndDecelerating()
}
さいごに
今回実装したソースコード全体は、GitHubにpushしていますm(_ _)m
実装過程で、iOS13ではちゃんとNavigationBarが隠れたのにiOS14では同じロジックでちゃんと隠れてくれなかったりして
思いの外大変でした。。
現状の実装では、非表示状態から再表示される時など見え方が微妙なところもまだまだあるので
もう少しきれいに表示/非表示切り替えられるようアプデしていきたいなーと思います|ω・`)
明日の投稿もお楽しみに〜(・ω・)ノ