1. はじめに
最近はなかなかまとまった時間が取れずにかなりサボり気味でしたが、ぼちぼち再開していければと思っています。
現在は本業もiOSエンジニアとなり、XCodeやSwift(たまにObjective-C)と戯れながらUIをあれこれ考えながらの毎日を送っています。最近もiPhoneXが日本でも発売されたり等もあったり、その対応をどうするか等も今考えないといけないトピックスになっているので、色々と試行錯誤をしています。
今回は、UIのスクロールやタブUIの切り替えを伴うようなコンテンツにて動きの中でポイントとなりそうな部分にアニメーションを入れたサンプルの紹介になります。
※ ボリュームの関係で前編・後編を分割してお送り致します。
また恐縮ですが実装の際に書いたノートの内容も併せて掲載する形にしようと思います。
Githubでのサンプルコード:
サンプルの全体的な動きの動画:
※こちらのサンプルはPullRequestやIssue等はお気軽にどうぞ!
この内容のダイジェスト資料:
更新履歴:
- 2021.02.14: サンプルコードをバージョンアップしました(Xcode12.3 & Swift5.3)
- 2018.11.21: サンプルコードをバージョンアップしました(Xcode10.1 & Swift4.2)
2. 今回の参考アプリとサンプル概要について
現在の業務ではiOSの中でもUI構築やアニメーション等の実装に携わる機会が多かったことや、アニメーションの設計を行う上で下記の資料等を見た際に、不自然にならない心地よいタイミングでの動きをUIに盛り込むプラクティスをしてみたいという思いもあり、今回のサンプルを作成するに至りました。
★2-1. インスパイアを受けた参考記事やアプリに関して:
今回のサンプルで参考にしたアプリは下記のアプリになります。
- アプリ: AWA
- サイト: https://awa.fm/
デザイン面もさることながら、UIの中に随所に自然な形で散りばめられた心地の良いアニメーションが素敵なアプリで、個人的に憧れているアプリの一つです。
動きの部分に関しては、全ての細かな部分までは難しそうだったので、今回のサンプルに関しては、主に下記の3つの機能についてを実装しています。
- UITableViewのセルが出現した際にふわっとフェードインがかかる動き (特にAWAの動きを参考にした部分)
- スクロールの変化量に伴って他のView要素の位置が切り替わる動き (ヘッダーの動き方はReactNativeのサンプルもプラスで参考にした部分)
- UIButtonやUILabelに一工夫を加えた動き (独自で実装をした部分)
更に個人的な取り組みとして、ReactNativeにも取り組んでいた経緯もあり、実際の動き(特にヘッダー画像とナビゲーションバーの動き)に関しては下記の記事やサンプルで紹介されている動きや解説も参考にしてみました。
動きの参考にした記事:
- React Native ScrollView animated header
- Declare Peace with React Native Animations
- React Native Parallax Scroll with Tabs
動きの参考にしたReactNativeライブラリ:
★2-2. 今回のサンプルについて:
サンプルのキャプチャ画像その1:
サンプルのキャプチャ画像その2:
環境やバージョンについて:
- Xcode12.3
- Swift5.3
- MacOS Big Sur (Ver11.2.1)
使用ライブラリ:
HTTPS通信で自作のJSONデータを返すAPIサンプル(記事詳細へ遷移した際に実行される)から内容を取得して表示させる部分に関しては、__「Alamofire&SwiftyJSONの組み合わせ」で行うようにしています。
また画像URLから画像のキャッシュさせて読み込みの高速化を図るためのライブラリ(SDWebImage)やFontAwesomeをSwiftで利用できるライブラリ(FontAwesome.swift)__も併せて活用しています。
| ライブラリ名 | ライブラリの機能概要 |
|:-----------|:------------|:------------|
|FontAwesome.swift |「Font Awesome」アイコンの利用 |
|SwiftyJSON |JSONデータの解析をしやすくする |
|Alamofire |HTTP/HTTPSのネットワーク通信用 |
|SDWebImage |画像URLからの非同期での画像表示とキャッシュサポート |
|PromiseKit | APIリクエスト送信&レスポンス取得の非同期処理ハンドリング |
Podfile内の設定は下記のようになります。
# Uncomment the next line to define a global platform for your project
platform :ios, '13.0'
target 'InteractiveUISample' do
# Comment the next line if you're not using Swift and don't want to use dynamic frameworks
use_frameworks!
# Pods for InteractiveUISample
# Utility
pod 'Alamofire'
pod 'PromiseKit'
pod 'SwiftyJSON'
pod 'SDWebImage'
# UserInterface
pod 'FontAwesome.swift'
end
※ FontAwesomeで使用できるアイコンに関しては、Font Awesomeが参考になるかと思いますので是非リポジトリのREADMEと併せて目を通して置くと良いかと思います。
補足事項:
- AlamofireについてはVer5.x系からは実装方法が大きく変化があった部分になります。
→ Alamofire 5 Tutorial for iOS: Getting Started - API通信処理部分における
Success(成功)
&Failure(失敗)
時のハンドリング処理部分にはPromiseKitを利用してPresenter側でも処理がわかりやすくなる様にしています。
今回のサンプルを作成する上での前提としては、アニメーションに関わる部分はなるべくDIYするのが基本の方針なのでライブラリはなるべく最小限に留めてあります。
3. 今回作成したサンプルの画面遷移図とStoryboard構成・アーキテクチャ等に関する解説
今回のサンプルは解説が前編・後編に分けて説明することや、画面数はそれほどなくても表示するViewに関するファイルが多くなるので、実装の際にも整理がしやすいようにするために行った事や気をつけた点についてここでは紹介をしていきます。
★3-1. Storyboard構成:
Storyboardの設計に関しては、下記のような形で構成しています。
最初の画面に関しては、UIScrollViewの中にContainerViewが2つあり、画面を左右のスワイプないしはタブのボタン押下時に切り替わる様な構成にしています。
また、最初の画面から記事詳細画面へ遷移する部分に関してはNavigationBarのカスタマイズを伴う部分になるので、実態はPresent Modally
の遷移にはなりますが、なるべくNavigationBarの遷移に近い形にするためにカスタムトランジション(ArticleCustomTransition.swift
)を適用させています。
import Foundation
import UIKit
class ArticleCustomTransition: NSObject {
//トランジション(実行)の秒数
fileprivate let duration: TimeInterval = 0.30
//ディレイ(遅延)の秒数
fileprivate let delay: TimeInterval = 0.00
//トランジションの方向(present: true, dismiss: false)
var presenting = true
}
extension ArticleCustomTransition: UIViewControllerAnimatedTransitioning {
//アニメーションの時間を定義する
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
/**
* アニメーションの実装を定義する
* この場合には画面遷移コンテキスト(UIViewControllerContextTransitioningを採用したオブジェクト)
* → 遷移元や遷移先のViewControllerやそのほか関連する情報が格納されているもの
*/
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
//コンテキストを元にViewのインスタンスを取得する(存在しない場合は処理を終了)
guard let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from) else {
return
}
guard let toView = transitionContext.view(forKey: UITransitionContextViewKey.to) else {
return
}
//アニメーションの実態となるコンテナビューを作成する
let containerView = transitionContext.containerView
//ContainerView内の左右のオフセット状態の値を決定する
let offScreenRight = CGAffineTransform(translationX: containerView.frame.width, y: 0)
let offScreenLeft = CGAffineTransform(translationX: -containerView.frame.width, y: 0)
//遷移先のViewControllerの初期位置を決定する
if presenting {
toView.transform = offScreenRight
} else {
toView.transform = offScreenLeft
}
//アニメーションの実体となるContainerViewに必要なものを追加する
containerView.addSubview(toView)
//NavigationControllerに似たアニメーションを実装する
UIView.animate(withDuration: duration, delay: delay, options: .curveEaseInOut, animations: {
//遷移元のViewControllerを移動させる
if self.presenting {
fromView.transform = offScreenLeft
} else {
fromView.transform = offScreenRight
}
//遷移先のViewControllerが画面に表示されるようにする
toView.transform = CGAffineTransform.identity
}, completion:{ finished in
transitionContext.completeTransition(true)
})
}
}
iOSのデフォルトの遷移アニメーションも比較的綺麗ではありますが、UIの触り心地や使い勝手を更に深堀りする必要がある場合には、是非とも考慮に入れたい部分の1つではあるかなと考えています。
★3-2. 選択したアーキテクチャ:
UI作成の上でデータとの連携部分に関しては、下記のような形で__「View - Presenter - Modelパターン」__での実装をしています。今回の意図としては、APIでの非同期通信をはじめとするデータ取得とUI表示の関係性を整理し、お互いの関係性を明確にする意図も込めて導入を試してみました。
具体的な例として、記事の詳細を取得する部分の実装についてざっくりと見ると下記のような流れになります。
APIの通信処理の有無に関わらず、データの流れやViewController側の処理の整理を行うようにしてみました。
1. Model側の実装:
この部分では、プロパティの定義とJSONデータをパース処理に関する記述をしており、SwiftyJSONを利用しています。APIの通信処理を伴う部分についてはイニシャライザには取得したJSONを引数に取るように定義します。
import Foundation
import UIKit
import SwiftyJSON
struct Article {
//メンバ変数(取得したJSONレスポンスのKeyに対応する値が入る)
let id: Int
let thumbnailUrl: String
let title: String
let category: String
let mainText: String
let viewsCounter: Int
let likesCounter: Int
let summaryTitle: String
let summaryText: String
let publishedAt: String
//イニシャライザ(取得したJSONレスポンスに対して必要なものを抽出する)
init(json: JSON) {
self.id = json["id"].int ?? 0
self.thumbnailUrl = json["thumbnailUrl"].string ?? ""
self.title = json["title"].string ?? ""
self.category = json["category"].string ?? ""
self.mainText = json["mainText"].string ?? ""
self.viewsCounter = json["viewsCounter"].int ?? 0
self.likesCounter = json["likesCounter"].int ?? 0
self.summaryTitle = json["summaryTitle"].string ?? ""
self.summaryText = json["summaryText"].string ?? ""
self.publishedAt = json["publishedAt"].string ?? ""
}
}
2. Presenter側の実装:
この部分では、APIの通信処理が完了したタイミングで、ViewController側で行う処理をプロトコルで定義して置くことと、APIの通信処理でのデータを取得 & レスポンス結果に応じたハンドリングの処理の2つの役割を行っています。
このような形にすることで、結果が返されたタイミングですぐにViewControllerの振る舞いが変わるようにしています。
import Foundation
import UIKit
import SwiftyJSON
//ArticleViewController側で実行したい処理をプロトコルに定義しておく
protocol ArticlePresenterProtocol: class {
func showArticle(_ article: Article)
func hideArticle()
}
lass ArticlePresenter {
var presenter: ArticlePresenterProtocol!
//MARK: - Initializer
init(presenter: ArticlePresenterProtocol) {
self.presenter = presenter
}
//MARK: - Functions
//サンプル記事データを取得する
func getArticle() {
let apiRequestManager = APIRequestManager(endPoint: "article.json", method: .get)
apiRequestManager.request()
.done { json in
//通信成功時の処理をプロトコルを適用したViewController側で行う
let article = Article.init(json: json)
self.presenter.showArticle(article)
}
.catch { _ in
//通信失敗時の処理をプロトコルを適用したViewController側で行う
self.presenter.hideArticle()
}
}
}
また、APIでの非同期通信を実行する部分につきましては、Alamofireを5.x系に更新した影響もあり、AlamofireでのAPI通信をする部分に対してPromiseKitを組み合わせた形で下記の様な処理としています。
※ この部分につきましては、記事加筆時にサンプルコードにも大きな変更を加えた部分になります。
struct APIRequestManager {
//APIのベースとなるURL情報
private let apiBaseURL = "https://...(表示用データのJSONを取得するためのエンドポイント).../"
//Header情報
private static let requestHeader = HTTPHeaders(
arrayLiteral: HTTPHeader(name: "User-Agent", value: ""),
HTTPHeader(name: "Content-Type", value: "application/x-www-from-urlencoded")
)
//URLアクセス用のメンバ変数
let apiUrl: String
let method: HTTPMethod
let parameters: Parameters
init(endPoint: String, method: HTTPMethod = .get, parameters: Parameters = [:]) {
//イニシャライザの定義
apiUrl = apiBaseURL + endPoint
self.method = method
self.parameters = parameters
}
//該当APIのエンドポイントに向けてデータを取得する
func request() -> Promise<JSON> {
return Promise { seal in
//Alamofireによる非同期通信
AF.request(apiUrl, method: method, parameters: parameters, encoding: URLEncoding.default, headers: APIRequestManager.requestHeader).validate().responseJSON { response in
switch response.result {
// 成功時の処理(以降はレスポンス結果を取得して返す)
case .success(let response):
let json = JSON(response)
seal.fulfill(json)
// 失敗時の処理(以降はエラーの結果を返す)
case .failure(let error):
seal.reject(error)
}
}
}
}
}
3. ViewController側の実装:
この部分では、Presenter側で定義したプロトコルに対しての具体的な実装を行っていくようにします。
Presenter側で行われたAPIの通信処理の結果を元にしてViewController側のUIに関する処理を行うような形になるように実装をしています。
import UIKit
class ArticleViewController: UIViewController {
//UI部品の配置
@IBOutlet var articleTableView: UITableView!
・・・(省略)・・・
//記事上の画像ヘッダーおよびナビゲーションバーのインスタンス作成
fileprivate var gradientHeaderView: GradientHeaderView = GradientHeaderView()
fileprivate var articleHeaderView: ArticleHeaderView = ArticleHeaderView()
//記事コンテンツを格納するための変数
fileprivate var articleContents: [Article] = [] {
didSet {
self.articleTableView.reloadData()
if let article = self.articleContents.first {
self.gradientHeaderView.setTitle(article.title)
self.articleHeaderView.setHeaderImage(article.thumbnailUrl)
}
}
}
//ArticlePresenterに設定したプロトコルを適用するための変数
fileprivate var presenter: ArticlePresenter!
・・・(省略)・・・
override func viewDidLoad() {
super.viewDidLoad()
・・・(省略)・・・
setupArticlePresenter()
}
・・・(省略)・・・
//MARK: - Private Function
・・・(省略)・・・
//Presenterとの接続に関する設定を行う
private func setupArticlePresenter() {
presenter = ArticlePresenter(presenter: self)
presenter.getArticle()
}
}
//MARK: - ArticlePresenterProtocol
extension ArticleViewController: ArticlePresenterProtocol {
//Presenter側で通信が成功した場合の処理
func showArticle(_ article: Article) {
articleContents.append(article)
・・・(記事をUITableViewに表示させる処理を行う)・・・
}
//Presenter側で通信が失敗した場合の処理
func hideArticle() {
・・・(エラーメッセージやアラートを表示させる処理を行う)・・・
}
}
今回の様にデータ取得やAPI通信処理が行われたタイミングに応じてUI側の振る舞いを変更させたい様な場合には、このような形にデータの流れや処理を整理することで、複雑なUI構築の際にもハンドリングをしやすくする様にすると良いかと思います。
構成の参考にした記事やサンプルコード:
- コードで覚えるクリーンアーキテクチャ 〜VP部分を書いてみよう〜
- A dumb UI is a good UI: Using MVP in iOS with swift
- iOSDesignPatternSamples 【MVPパターン】
- iOS-mvp-sample
★3-3. 部品としてできるだけ考えるような構成をする:
ただのViewとしてUIパーツを分割して考えたい場合には、UIViewを継承したベースとなるクラスとCustomViewBase.swift
を作成し、Viewの部品にtついては、__「CustomViewBaseクラスを継承したクラスとXibファイルを1セット」__で分割するようにして、複雑な構造になる部分はStoryboardに押し込めすぎないようにしています。
(UITableViewCellを継承したクラスに関しても同様に分割するようにしています。)
import Foundation
import UIKit
//自作のXibを使用するための基底となるUIViewを継承したクラス
//参考:http://skygrid.co.jp/jojakudoctor/swift-custom-class/
class CustomViewBase: UIView {
//コンテンツ表示用のView
weak var contentView: UIView!
//このカスタムビューをコードで使用する際の初期化処理
required override init(frame: CGRect) {
super.init(frame: frame)
initContentView()
}
//このカスタムビューをInterfaceBuilderで使用する際の初期化処理
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
initContentView()
}
//コンテンツ表示用Viewの初期化処理
private func initContentView() {
//追加するcontentViewのクラス名を取得する
let viewClass: AnyClass = type(of: self)
//追加するcontentViewに関する設定をする
contentView = Bundle(for: viewClass)
.loadNibNamed(String(describing: viewClass), owner: self, options: nil)?.first as? UIView
contentView.autoresizingMask = autoresizingMask
contentView.frame = bounds
contentView.translatesAutoresizingMaskIntoConstraints = false
addSubview(contentView)
//追加するcontentViewの制約を設定する ※上下左右へ0の制約を追加する
let bindings = ["view": contentView as Any]
let contentViewConstraintH = NSLayoutConstraint.constraints(
withVisualFormat: "H:|[view]|",
options: NSLayoutFormatOptions(rawValue: 0),
metrics: nil,
views: bindings
)
let contentViewConstraintV = NSLayoutConstraint.constraints(
withVisualFormat: "V:|[view]|",
options: NSLayoutFormatOptions(rawValue: 0),
metrics: nil,
views: bindings
)
addConstraints(contentViewConstraintH)
addConstraints(contentViewConstraintV)
}
}
Viewを部品として分割する粒度をあまり細かくし過ぎると、帰って見通しが悪くなってしまうこともあるので、この部分は複雑さに応じて対応すると良いかと思います。
また、実際のXcode内では下記のように切り出したUI部品の名前をしっかりと決めた上で、
- UI部品の見た目に関する初期設定などは先に適用しておく
- ViewControllerで呼び出す際にわかりやすいようにメソッドや橋渡しのクロージャーを用意する
という感じで、UIを構成するためのルールや取り決めをしておくとさらに管理が捗るように思います。
アプリ開発において複雑なUIを作成しなければならない際や、機能の追加等で画面数が多くなる場合にはこのように後々の管理を見越した上での部品への切り分けを意識すると良いかもしれません。
4. ScrollViewを利用したタブメニュー部分のUI構成とコンテンツの実装に関する解説
アプリのUIを作成する上ではUITableViewやUIScrollViewを活用することが非常に多いと思います。
またUITableViewDelegateやUIScrollViewDelegateのメソッドやアニメーションを活用することによって、スクロール時の動きや表現に彩りやワンポイントを加えたUIにすることができます。
今回のサンプルでMainViewController.swift
で使用した実装に関するTipsを紹介していければと思います。
★4-1. UIScrollViewとContainerViewを組み合わせてタブメニューUIを作成する:
コンテンツのベースとなる見た目に関しては、タブ用とコンテンツ表示用のUIScrollViewを下記のような形でまずは配置して、その中にコンテンツを表示するためのContainerViewを2つ配置し、タブ用のScrollViewの中にはどの画面に自分がいるかが分かるように動くバーも設置します。
UIScrollView内に配置したContainerViewに対するAutoLayoutの制約は下記のような形になっていればOKです。
またそれぞれのUIScrollViewで行われる処理の概要としては、
- コンテンツ用のScrollViewをスワイプすると、動くバーと一緒にコンテンツが切り替わる
- タブ用のScrollViewに設置しているボタンを押下すると、0.26秒間のスライドでコンテンツが切り替わる
という動きの形になります。
MainViewController.swift
(それぞれのコンテンツを表示するベースとなるViewController)の処理は総括すると下記のような形になります。
import UIKit
class MainViewController: UIViewController {
@IBOutlet weak var navigationScrollView: UIScrollView!
@IBOutlet weak var contentsScrollView: UIScrollView!
//ナビゲーション用のScrollViewの中に入れる動く下線用のView
fileprivate var bottomLineView: UIView = UIView()
//ナビゲーション用のScrollViewの中に入れる動く下線用のViewの高さ
fileprivate let navigationBottomLinePositionHeight: Int = 2
//ナビゲーションのボタン名
private let navigationNameList: [String] = ["新着特集", "コンテンツ紹介"]
//UIScrollView内のレイアウト決定に関する処理 ※この中でviewDidLayoutSubviewsで行うUI部品の初期配置に関する処理を行う
private lazy var setNavigationScrollView: (() -> ())? = {
setupButtonsInNavigationScrollView()
setupBottomLineInNavigationScrollView()
return nil
}()
//スクロールビューの識別用タグ定義
private enum ScrollViewIdentifier: Int {
case navigation = 0
case contents
}
override func viewDidLoad() {
super.viewDidLoad()
setupNavigationBar()
setupContentsScrollView()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
//ナビゲーション用のスクロールビューに関する設定をする
setNavigationScrollView?()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
//MARK: - Private Function
//ナビゲーションに配置されたボタンを押した時のアクション設定
@objc private func navigationScrollViewButtonTapped(button: UIButton) {
//押されたボタンのタグを取得
let page: Int = button.tag
//ナビゲーション用のボタンが押された場合は
animateBottomLineView(Double(page), actionIdentifier: .navigationButtonTapped)
animateContentScrollView(page)
}
//この画面のナビゲーションバーの設定
private func setupNavigationBar() {
//NavigationControllerのデザイン調整を行う
self.navigationController?.navigationBar.tintColor = UIColor.white
self.navigationController?.navigationBar.titleTextAttributes = [NSAttributedStringKey.foregroundColor : UIColor.white]
//タイトルを入れる
self.navigationItem.title = "海の見える風景"
}
//コンテンツ表示用のUIScrollViewの設定
private func setupContentsScrollView() {
contentsScrollView.delegate = self
contentsScrollView.isPagingEnabled = true
contentsScrollView.showsHorizontalScrollIndicator = false
}
//ボタン表示用のUIScrollViewの設定
//MEMO: private lazy var setNavigationScrollView: (() -> ())? 内に設定
private func setupButtonsInNavigationScrollView() {
//スクロールビュー内のサイズを決定する
let navigationScrollViewWidth = Int(navigationScrollView.frame.width)
let navigationScrollViewHeight = Int(navigationScrollView.frame.height)
navigationScrollView.contentSize = CGSize(width: navigationScrollViewWidth, height: navigationScrollViewHeight)
//スクロールビュー内にUIButtonを配置する
for i in 0..<navigationNameList.count {
let button = UIButton(
frame: CGRect(
x: CGFloat(navigationScrollViewWidth / 2 * i),
y: CGFloat(0),
width: CGFloat(navigationScrollViewWidth / 2),
height: CGFloat(navigationScrollViewHeight)
)
)
button.backgroundColor = UIColor.clear
button.titleLabel!.font = UIFont(name: AppConstants.BOLD_FONT_NAME, size: 14)!
button.setTitle(navigationNameList[i], for: UIControlState())
button.setTitleColor(ColorDefinition.navigationColor.getColor(), for: UIControlState())
button.tag = i
button.addTarget(self, action: #selector(self.navigationScrollViewButtonTapped(button:)), for: .touchUpInside)
navigationScrollView.addSubview(button)
}
}
//ナビゲーション用のScrollViewの中に入れる動く下線の設定
//MEMO: private lazy var setNavigationScrollView: (() -> ())? 内に設定
private func setupBottomLineInNavigationScrollView() {
let navigationScrollViewWidth = Int(navigationScrollView.frame.width)
let navigationScrollViewHeight = Int(navigationScrollView.frame.height)
bottomLineView.frame = CGRect(
x: CGFloat(0),
y: CGFloat(navigationScrollViewHeight - navigationBottomLinePositionHeight),
width: CGFloat(navigationScrollViewWidth / 2),
height: CGFloat(navigationBottomLinePositionHeight)
)
bottomLineView.backgroundColor = ColorDefinition.navigationColor.getColor()
navigationScrollView.addSubview(bottomLineView)
navigationScrollView.bringSubview(toFront: bottomLineView)
}
}
//MARK: - UIScrollViewDelegate
extension MainViewController: UIScrollViewDelegate {
//animateBottomLineViewを実行する際に行われたアクションを識別するためのenum値
fileprivate enum ActionIdentifier {
case contentsSlide
case navigationButtonTapped
//対応するアニメーションの秒数を返す
func duration() -> Double {
switch self {
case .contentsSlide:
return 0
case .navigationButtonTapped:
return 0.26
}
}
}
//スクロールが発生した際に行われる処理 (※ UIScrollViewDelegate)
func scrollViewDidScroll(_ scrollview: UIScrollView) {
//現在表示されているページ番号を判別する
let pageWidth = contentsScrollView.frame.width
let fractionalPage = Double(contentsScrollView.contentOffset.x / pageWidth)
//ボタン配置用のスクロールビューもスライドさせる
animateBottomLineView(fractionalPage, actionIdentifier: .contentsSlide)
}
//ナビゲーション用のScrollViewの中に入れる動く下線を所定位置まで動かす
fileprivate func animateBottomLineView(_ page: Double, actionIdentifier: ActionIdentifier) {
let navigationScrollViewWidth = Int(navigationScrollView.frame.width)
let navigationScrollViewHeight = Int(navigationScrollView.frame.height)
//X軸方向の動かす終点位置を決める
let positionX = Double(navigationScrollViewWidth / 2) * page
UIView.animate(withDuration: actionIdentifier.duration(), animations: {
self.bottomLineView.frame = CGRect(
x: CGFloat(positionX),
y: CGFloat(navigationScrollViewHeight - self.navigationBottomLinePositionHeight),
width: CGFloat(navigationScrollViewWidth / 2),
height: CGFloat(self.navigationBottomLinePositionHeight)
)
})
}
//コンテンツ用のScrollViewを所定位置まで動かす
fileprivate func animateContentScrollView(_ page: Int) {
UIView.animate(withDuration: 0.26, animations: {
self.contentsScrollView.contentOffset = CGPoint(
x: Int(self.contentsScrollView.frame.width) * page,
y: 0
)
})
}
}
タブメニューの実装に関しては、UIPageViewController
を用いる方法もありますが動く今回は表示する方法もありますが、今回はコンテンツが少ないことや動くバーを伴う動きの実装のしやすさでこの形を選びましたが、この部分についてはアプリの設計によって選択すると良いかと思います。
★4-2. サムネイル画像のパララックス(視差効果)表現とフェードするアニメーションを組み合わせた表現を作る:
セルをスクロールさせた際に、サムネイル画像のパララックス(視差効果)表現とセルの中身が出現するタイミングでふわっとフェードするアニメーションを組み合わせた表現をしています。この部分に関しては下記のような形で、「AutoLayoutのConstraintの変更を利用したアニメーション」と「セル自体のアルファ値を変更するCoreAnimation」のそれぞれの異なるアニメーションをそれぞれのタイミングに合わせて表示させるようにしています。
1.MainListViewController.swift(extension設定側)
//MARK: - UITableViewDelegate, UIScrollViewDelegate
extension MainListViewController: UITableViewDelegate, UIScrollViewDelegate {
//MARK: - UITableViewDelegate
//セルを表示しようとする時の動作を設定する
/**
* willDisplay(UITableViewDelegateのメソッド)に関して
*
* 参考: Cocoa API解説(macOS/iOS) tableView:willDisplayCell:forRowAtIndexPath:
* https://goo.gl/Ykp30Q
*/
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
//MainListTableViewCell型へダウンキャストする
let mainListTableViewCell = cell as! MainListTableViewCell
//セル内の画像のオフセット値を変更する
setCellImageOffset(mainListTableViewCell, indexPath: indexPath)
//セルへフェードインのCoreAnimationを適用する
setCellFadeInAnimation(mainListTableViewCell)
}
//MARK: - UIScrollViewDelegate
//スクロールが検知された時に実行される処理
func scrollViewDidScroll(_ scrollView: UIScrollView) {
//パララックスをするテーブルビューの場合
if scrollView == mainListTableView {
for indexPath in mainListTableView.indexPathsForVisibleRows! {
//画面に表示されているセルの画像のオフセット値を変更する
setCellImageOffset(mainListTableView.cellForRow(at: indexPath) as! MainListTableViewCell, indexPath: indexPath)
}
}
}
//UITableViewCell内のオフセット値を再計算して視差効果をつける
private func setCellImageOffset(_ cell: MainListTableViewCell, indexPath: IndexPath) {
//MainListTableViewCellの位置関係から動かす制約の値を決定する
let cellFrame = mainListTableView.rectForRow(at: indexPath)
let cellFrameInTable = mainListTableView.convert(cellFrame, to: mainListTableView.superview)
let cellOffset = cellFrameInTable.origin.y + cellFrameInTable.size.height
let tableHeight = mainListTableView.bounds.size.height + cellFrameInTable.size.height
let cellOffsetFactor = cellOffset / tableHeight
//画面に表示されているセルの画像のオフセット値を変更する
cell.setBackgroundOffset(cellOffsetFactor)
}
//UITableViewCellが表示されるタイミングにフェードインのアニメーションをつける
private func setCellFadeInAnimation(_ cell: MainListTableViewCell) {
/**
* CoreAnimationを利用したアニメーションをセルの表示時に付与する(拡大とアルファの重ねがけ)
*
* 参考:【iOS Swift入門 #185】Core Animationでアニメーションの加速・減速をする
* http://swift-studying.com/blog/swift/?p=1162
*/
//アニメーションの作成
let groupAnimation = CAAnimationGroup()
groupAnimation.fillMode = kCAFillModeBackwards
groupAnimation.duration = 0.36
groupAnimation.beginTime = CACurrentMediaTime() + 0.08
groupAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn)
//透過を変更するアニメーション
let opacityAnimation = CABasicAnimation(keyPath: "opacity")
opacityAnimation.fromValue = 0.00
opacityAnimation.toValue = 1.00
//作成した個別のアニメーションをグループ化
groupAnimation.animations = [opacityAnimation]
//セルのLayerにアニメーションを追加
cell.layer.add(groupAnimation, forKey: nil)
//アニメーション終了後は元のサイズになるようにする
cell.layer.transform = CATransform3DIdentity
}
}
2.MainListTableViewCell.swift(サムネイル画像のパララックス効果)
import UIKit
import FontAwesome_swift
class MainListTableViewCell: UITableViewCell {
・・・(省略)・・・
//UIViewに内包したUIImageViewの上下の制約
@IBOutlet weak var topImageViewConstraint: NSLayoutConstraint!
@IBOutlet weak var bottomImageViewConstraint: NSLayoutConstraint!
//視差効果のズレを生むための定数(大きいほど視差効果が大きい)
private let imageParallaxFactor: CGFloat = 75
・・・(省略)・・・
//視差効果の計算用の変数
private var imageBackTopInitial: CGFloat!
private var imageBackBottomInitial: CGFloat!
・・・(省略)・・・
//画像にかけられているAutoLayoutの制約を再計算して制約をかけ直す
func setBackgroundOffset(_ offset: CGFloat) {
let boundOffset = max(0, min(1, offset))
let pixelOffset = (1 - boundOffset) * 2 * imageParallaxFactor
topImageViewConstraint.constant = imageBackTopInitial - pixelOffset
bottomImageViewConstraint.constant = imageBackBottomInitial + pixelOffset
}
・・・(省略)・・・
}
アニメーションの実装に関しては、UIViewのクラスメソッド(.animate()
)やAutoLayoutの制約値の変更を用いるだけではなく、場合によっては細かな描画や調整が必要なケースがある場合にはCoreAnimationの実装も検討してみても良いかと思います。
アニメーション実装参考資料:
心地の良いアニメーションを伴うUIというテーマに関しては今後とも追いかけていきたいと思う次第です。
★4-3. その他ScrollViewを利用して複雑なレイアウトを作成する上で気をつけると良い点:
コンテンツの分量が多い場合やView要素が複雑になる場合には、ScrollViewやContainerViewとEmbedSegueで繋がっているViewControllerとそれに伴って表示するView要素及び、離れたViewControllerに対して橋渡しをするためのNotification等の処理が増えやすいので、お互いにどのような管理をしているのかを整理と把握をしやすく整えておくことも大切なポイントになるかと思います。
上記のようにタブ型UIの2番目のコンテンツでは、UIScrollViewの中にさらにレイアウトの外枠の役割をするUIStackViewがありその中には、
- デバイスによって高さが可変するカスタムView
- バナー型の高さが変わらないカスタムView
- 高さが可変するセルを表示するUITableViewを配置したViewControllerと繋がっているContainerView
を配置しています。高さが可変するセルであっても、UITableViewのニュース状コンテンツを表示するために、ViewControllerのライフサイクルとNotificationを活用して高さの調節を行うような形にしています。
※処理の詳細はサンプルファイル内の
-
MainActivityViewController.swift
のNotificationに関する処理 -
MainActivityNewsViewController.swift
のviewDidLayoutSubviews及びviewDidAppearの処理
を参照して頂ければと思います。
5. メディア系のアプリの記事ページでよく見るヘッダー画像や動くナビゲーションバー部分のUI構成と実装に関する解説
今回のサンプル記事表示の画面については、様々なメディア等をはじめとする読み物系のアプリのUIに近しい形にしています。
スクロールの変化に応じて画面TOP部分に配置されている画像やナビゲーションバーが視差効果を伴ってアニメーションをする動きは、よく見る表現の1つかと思いますが、今回はこの部分をDIYしてみました。
この方法が正攻法の実装かはわかりませんが、UIScrollViewDelegateやAutoLayoutを活用したUIを作成する上で、少しでも参考になれば幸いに思います。
★5-1. スクロールの変化量に応じてヘッダー画像とナビゲーションを変化させる部分の実装:
記事表示部分のViewController(ArticleViewController.storyboard
)の構成は下記のような形にします。NavigationBarの中にダミーのヘッダーになるViewを配置しておき、また記事のヘッダーに表示する画像を記事表示のUITableViewのヘッダーとして設定しておきます。
また、記事のスクロール量の変化に伴って、ヘッダー画像の伸縮やナビゲーションの出現する動きをコントロールすることができるようにクラス内にメソッドを用意します。いずれもArticleViewController側から変化したスクロール量を受け取り、それぞれのクラスでAutoLayoutの制約を再設定をすることでアニメーションを実現するようにしています。
今回のサンプルでの動きの状態としては、
- コンテンツが一番上にある状態で下にスクロールをすると、ヘッダー画像が伸びるような動きをする。
- コンテンツが一番上にある状態で上にスクロールをすると、ヘッダー画像がずれながらダミーのヘッダーが徐々に現れる。(背景のアルファ値が1に近づきながら、タイトルと戻るボタンが下から徐々に現れる)
- ヘッダー画像が完全に隠れたら、タイトルと戻るボタンは現れたままの状態になり、更に上へスクロールを続けても位置はそのまま固定されている。
という形になります。ヘッダー画像用Viewクラス(Xibなし)・ダミーのヘッダー用Viewクラスは下記のように
定義し、メソッド経由で位置の制約を変更ができるようにしています。
1. ArticleHeaderView.swift:
import UIKit
import SDWebImage
class ArticleHeaderView: UIView {
private var imageView = UIImageView()
private var imageViewHeightLayoutConstraint = NSLayoutConstraint()
private var imageViewBottomLayoutConstraint = NSLayoutConstraint()
private var wrappedView = UIView()
private var wrappedViewHeightLayoutConstraint = NSLayoutConstraint()
//MARK: - Initializer
//このカスタムビューをコードで使用する際の初期化処理
required override init(frame: CGRect) {
super.init(frame: frame)
setupArticleHeaderView()
}
//このカスタムビューをInterfaceBuilderで使用する際の初期化処理
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupArticleHeaderView()
}
//MARK: - Function
//バウンス効果のあるUIImageViewに表示する画像をセットする
func setHeaderImage(_ thumbnailUrl: String) {
imageView.sd_setImage(with: URL(string: thumbnailUrl))
}
//UIScrollView(今回はUITableView)の変化量に応じてAutoLayoutの制約を動的に変更する
func setParallaxEffectToHeaderView(_ scrollView: UIScrollView) {
//スクロールビューの上方向の余白の変化量をwrappedViewの高さに加算する
//参考:http://blogios.stack3.net/archives/1663
wrappedViewHeightLayoutConstraint.constant = scrollView.contentInset.top
//Y軸方向オフセット値を算出する
let offsetY = -(scrollView.contentOffset.y + scrollView.contentInset.top)
//Y軸方向オフセット値に応じた値をそれぞれの制約に加算する
wrappedView.clipsToBounds = (offsetY <= 0)
imageViewBottomLayoutConstraint.constant = (offsetY >= 0) ? 0 : -offsetY / 2
imageViewHeightLayoutConstraint.constant = max(offsetY + scrollView.contentInset.top, scrollView.contentInset.top)
}
//MARK: - Private Function
private func setupArticleHeaderView() {
self.backgroundColor = UIColor.white
/**
* ・コードでAutoLayoutを張る場合の注意点等の参考
*
* (1) Auto Layoutをコードから使おう
* http://blog.personal-factory.com/2016/01/11/make-auto-layout-via-code/
*
* (2) Visual Format Languageを使う【Swift3.0】
* http://qiita.com/fromage-blanc/items/7540c6c58bf9d2f7454f
*
* (3) コードでAutolayout
* http://qiita.com/bonegollira/items/5c973206b82f6c4d55ea
*/
//Autosizing → AutoLayoutに変換する設定をオフにする
wrappedView.translatesAutoresizingMaskIntoConstraints = false
wrappedView.backgroundColor = UIColor.white
self.addSubview(wrappedView)
//このViewに対してwrappedViewに張るConstraint(横方向 → 左:0, 右:0)
let wrappedViewConstarintH = NSLayoutConstraint.constraints(
withVisualFormat: "H:|[wrappedView]|",
options: NSLayoutFormatOptions(rawValue: 0),
metrics: nil,
views: ["wrappedView" : wrappedView]
)
//このViewに対してwrappedViewに張るConstraint(縦方向 → 上:なし, 下:0)
let wrappedViewConstarintV = NSLayoutConstraint.constraints(
withVisualFormat: "V:[wrappedView]|",
options: NSLayoutFormatOptions(rawValue: 0),
metrics: nil,
views: ["wrappedView" : wrappedView]
)
self.addConstraints(wrappedViewConstarintH)
self.addConstraints(wrappedViewConstarintV)
//wrappedViewの縦幅をいっぱいにする
wrappedViewHeightLayoutConstraint = NSLayoutConstraint(
item: wrappedView,
attribute: .height,
relatedBy: .equal,
toItem: self,
attribute: .height,
multiplier: 1.0,
constant: 0.0
)
self.addConstraint(wrappedViewHeightLayoutConstraint)
//wrappedViewの中にimageView入れる
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.backgroundColor = UIColor.white
imageView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill
wrappedView.addSubview(imageView)
//wrappedViewに対してimageViewに張るConstraint(横方向 → 左:0, 右:0)
let imageViewConstarintH = NSLayoutConstraint.constraints(
withVisualFormat: "H:|[imageView]|",
options: NSLayoutFormatOptions(rawValue: 0),
metrics: nil,
views: ["imageView" : imageView]
)
//wrappedViewの下から0pxの位置に配置する
imageViewBottomLayoutConstraint = NSLayoutConstraint(
item: imageView,
attribute: .bottom,
relatedBy: .equal,
toItem: wrappedView,
attribute: .bottom,
multiplier: 1.0,
constant: 0.0
)
//imageViewの縦幅をいっぱいにする
imageViewHeightLayoutConstraint = NSLayoutConstraint(
item: imageView,
attribute: .height,
relatedBy: .equal,
toItem: wrappedView,
attribute: .height,
multiplier: 1.0,
constant: 0.0
)
wrappedView.addConstraints(imageViewConstarintH)
wrappedView.addConstraint(imageViewBottomLayoutConstraint)
wrappedView.addConstraint(imageViewHeightLayoutConstraint)
}
}
2. GradientHeaderView.swiftのAutoLayout設定:
3. GradientHeaderView.swift:
import Foundation
import UIKit
class GradientHeaderView: CustomViewBase {
//UI部品の配置
@IBOutlet weak var headerBackButton: UIButton!
@IBOutlet weak private var headerWrappedViewTopConstraint: NSLayoutConstraint!
@IBOutlet weak private var headerTitle: UILabel!
private let defaultHeaderMargin: CGFloat = DeviceSize.sizeOfIphoneX() ? 44 : 20
//MARK: - Function
//ダミーのヘッダー内にあるタイトルをセットする
func setTitle(_ title: String?) {
headerTitle.text = title
}
//ダミーのヘッダーの上方向の制約を更新する
//[変数] constarint = (テーブルビューのヘッダー画像の高さ) - (NavigationBarの高さを引いたもの) - (テーブルビュー側の縦方向のスクロール量)
func setHeaderNavigationTopConstraint(_ constant: CGFloat) {
if constant > 0 {
headerWrappedViewTopConstraint.constant = defaultHeaderMargin + constant
} else {
headerWrappedViewTopConstraint.constant = defaultHeaderMargin
}
self.layoutIfNeeded()
}
}
ViewController側では、UITableViewのScrollViewDelegate
を利用してスクロールの変化量を基に、ダミーのヘッダー部分とヘッダー画像の動きを変化させるように定義します。
4. ArticleViewController.swift:
※ ここでは動きの表現に関する部分だけを抜粋しています。
import UIKit
class ArticleViewController: UIViewController {
//UI部品の配置
@IBOutlet var articleTableView: UITableView!
//記事上の画像ヘッダーのViewの高さ(iPhoneX用に補正あり)
private let articleHeaderImageViewHeight: CGFloat = DeviceSize.sizeOfIphoneX() ? 244 : 200
//グラデーションヘッダー用のY軸方向の位置(iPhoneX用に補正あり)
private let gradientHeaderViewPositionY: CGFloat = DeviceSize.sizeOfIphoneX() ? -44 : -20
//ナビゲーションバーの高さ(iPhoneX用に補正あり)
fileprivate let navigationBarHeight: CGFloat = DeviceSize.sizeOfIphoneX() ? 88.5 : 64.0
//記事上の画像ヘッダーおよびナビゲーションバーのインスタンス作成
fileprivate var gradientHeaderView: GradientHeaderView = GradientHeaderView()
fileprivate var articleHeaderView: ArticleHeaderView = ArticleHeaderView()
・・・(省略)・・・
override func viewDidLoad() {
super.viewDidLoad()
setupNavigationBar()
setupGradientHeaderView()
setupGradientHeaderImage()
・・・(省略)・・・
}
・・・(省略)・・・
//この画面のナビゲーションバーの設定
private func setupNavigationBar() {
//NavigationControllerのカスタマイズを行う
self.navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default)
self.navigationController?.navigationBar.shadowImage = UIImage()
self.navigationController?.navigationBar.tintColor = UIColor.white
self.navigationItem.hidesBackButton = true
}
//ダミーのヘッダービューに関するセッティングを行うメソッド
private func setupGradientHeaderView() {
//StatusBarの高さ分をマイナス&微調整してnavigationBarの中に配置する
gradientHeaderView.frame = CGRect(x: 0, y: gradientHeaderViewPositionY, width: self.view.bounds.width, height: navigationBarHeight)
self.navigationController?.navigationBar.addSubview(gradientHeaderView)
//初回配置時のアルファ値を0にする
gradientHeaderView.alpha = 0
//ダミーのヘッダービュー内に配置している戻るボタンとアクション対象メソッドの紐付けをする
gradientHeaderView.headerBackButton.addTarget(self, action: #selector(self.headerBackButtonAction), for: .touchUpInside)
}
//テーブルビューのヘッダー画像に関するセッティングを行う
private func setupGradientHeaderImage() {
articleHeaderView.frame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: articleHeaderImageViewHeight)
articleTableView.tableHeaderView = articleHeaderView
}
・・・(省略)・・・
}
・・・(省略)・・・
//MARK - UIScrollViewDelegate
extension ArticleViewController: UIScrollViewDelegate {
//スクロールが検知された時に実行される処理
func scrollViewDidScroll(_ scrollView: UIScrollView) {
//テーブルビューのヘッダー画像に付与されているAutoLayout制約を変更してパララックス効果を出す
let articleHeaderView = articleTableView.tableHeaderView as! ArticleHeaderView
articleHeaderView.setParallaxEffectToHeaderView(scrollView)
//ダミーのヘッダービューのアルファ値を上方向のスクロール量に応じて変化させる
/*
それぞれの変数の意味と変化量と伴って変わる値に関する補足:
[変数] navigationInvisibleHeight = (テーブルビューのヘッダー画像の高さ) - (NavigationBarの高さを引いたもの)
gradientHeaderViewのアルファの値 = 上方向のスクロール量 ÷ navigationInvisibleHeightとする
アルファの値域:(0 ≦ gradientHeaderView.alpha ≦ 1)
*/
let navigationInvisibleHeight = articleHeaderView.frame.height - navigationBarHeight
let scrollContentOffsetY = scrollView.contentOffset.y
if scrollContentOffsetY > 0 {
gradientHeaderView.alpha = min(scrollContentOffsetY / navigationInvisibleHeight, 1)
} else {
gradientHeaderView.alpha = max(scrollContentOffsetY / navigationInvisibleHeight, 0)
}
//ダミーのヘッダービューの中身の戻るボタンとタイトルを包んだViewの上方向の制約を更新する
let targetTopConstraint = navigationInvisibleHeight - scrollContentOffsetY
gradientHeaderView.setHeaderNavigationTopConstraint(targetTopConstraint)
}
}
・・・(省略)・・・
ポイントとなる点としては、UIScrollView(今回の場合はUITableView)のScrollViewDidScrollにて現在のスクロール量を取得し、この値を基に配置したヘッダー画像及びダミーのヘッダーに設定したAutoLayoutの制約値を変更させることで、アニメーションをさせる実装を行う部分になります。
変化に伴って制約をうまく調節する点がなかなか難しいところですが、うまく決まると美しいレイアウトと動きを演出することができます。
★5-2. このUIでiPhoneXのSafeAreaとどの様に向き合うか?:
今年から新しく出てきたiPhoneXに関する対応も今回のサンプルでは、ヘッダーの部分に関してはiPhoneXのSafeAreaに対応するための処理を実施しています。
前述の通り、NavigationBarの中に、ダミーのヘッダーとなるGradientHeaderView.swift
を配置する形の作りになっているので、このヘッダーの高さを下記の図のようにiPhoneXとそれ以外のデバイスで出し分けをするようにしています。
また、iPhoneXとそれ以外の場合分けをするようなメソッドをあらかじめ用意して、GradientHeaderView.swift
の高さ設定用の定数に適用することで対応しました。
iPhoneXのデバイスかを判定する:
/**
* MEMO: Xcode10以降でビルドする際はこのファイルは必要ないので、
* 「補足: Xcode10でビルドする場合におけるこのリポジトリでのSafeArea関連部分の調整」
* を参考に実装を行なってください。
*/
import Foundation
struct DeviceSize {
//CGRectを取得
static func bounds() -> CGRect {
return UIScreen.main.bounds
}
//画面の横サイズを取得
static func screenWidth() -> Int {
return Int(self.bounds().width)
}
//画面の縦サイズを取得
static func screenHeight() -> Int {
return Int(self.bounds().height)
}
//iPhoneXのサイズとマッチしているかを返す
static func sizeOfIphoneX() -> Bool {
return (self.screenWidth() == 375 && self.screenHeight() == 812)
}
}
また、今回の場合はArticleViewController.swift
内の記事コンテンツ(UITableView
)のスクロール量に伴ってアルファや位置が変化する形にしているので、スクロール量に伴って変化する制約値の部分を、
- iPhoneXでは初期状態は高さが44px(iPhoneX以外では20px)
となるように調整をかけます。
ダミーのヘッダーになるViewクラス内での適用:
import Foundation
import UIKit
class GradientHeaderView: CustomViewBase {
・・・(省略)・・・
@IBOutlet weak private var headerWrappedViewTopConstraint: NSLayoutConstraint!
// Xcode10以降の場合の修正方法は後述の「補足: Xcode10でビルドする場合におけるこのリポジトリでのSafeArea関連部分の調整」を参照
private let defaultHeaderMargin: CGFloat = DeviceSize.sizeOfIphoneX() ? 44 : 20
・・・(省略)・・・
//ダミーのヘッダーの上方向の制約を更新する
//[変数] constarint = (テーブルビューのヘッダー画像の高さ) - (NavigationBarの高さを引いたもの) - (テーブルビュー側の縦方向のスクロール量)
func setHeaderNavigationTopConstraint(_ constant: CGFloat) {
if constant > 0 {
headerWrappedViewTopConstraint.constant = defaultHeaderMargin + constant
} else {
headerWrappedViewTopConstraint.constant = defaultHeaderMargin
}
self.layoutIfNeeded()
}
}
また、今回のサンプルでのSafeAreaの考慮に当たっては下記の資料を参考にしました。
SafeAreaに関する参考資料:
今回のように、ヘッダー部分やNavigationBarが絡むデザインや動きに関してカスタマイズを行う場合には、iPhoneXのSafeAreaの考慮をした上での設計やアニメーション実装を行わないとデザインが崩れてしまうのでしっかりと考慮をしなければいけない点であると改めて痛感しました。
補足: Xcode10でビルドする場合におけるこのリポジトリでのSafeArea関連部分の調整:
Xcode10以降でビルドをする場合においては、iPhone XR / XS / XS Max についてのサイズ考慮も必要になってきますので、前述したDeviceSize.swift
を利用しないで下記のような形で判定した方がより良いかと思います。
/* 修正前のSafeAreaを考慮した判定 */
private let defaultHeaderMargin: CGFloat = DeviceSize.sizeOfIphoneX() ? 44 : 20
/* 修正後のSafeAreaを考慮した判定 */
private let defaultHeaderMargin: CGFloat = UIApplication.shared.statusBarFrame.height
/* 修正前のSafeAreaを考慮した判定 */
//記事上の画像ヘッダーのViewの高さ(iPhoneX用に補正あり)
private let articleHeaderImageViewHeight: CGFloat = DeviceSize.sizeOfIphoneX() ? 244 : 200
//グラデーションヘッダー用のY軸方向の位置(iPhoneX用に補正あり)
private let gradientHeaderViewPositionY: CGFloat = DeviceSize.sizeOfIphoneX() ? -44 : -20
//ナビゲーションバーの高さ(iPhoneX用に補正あり)
fileprivate let navigationBarHeight: CGFloat = DeviceSize.sizeOfIphoneX() ? 88.5 : 64.0
/* 修正後のSafeAreaを考慮した判定 */
//記事上の画像ヘッダーのViewの高さ
private let articleHeaderImageViewHeight: CGFloat = {
if UIApplication.shared.statusBarFrame.height > 20 {
return 244.0
} else {
return 200.0
}
}()
//グラデーションヘッダー用のY軸方向の位置
private let gradientHeaderViewPositionY: CGFloat = -UIApplication.shared.statusBarFrame.height
//ナビゲーションバーの高さ
fileprivate let navigationBarHeight: CGFloat = {
if UIApplication.shared.statusBarFrame.height > 20 {
return 88.5
} else {
return 64.0
}
}()
6. その他このサンプルを作成するにあたり実装した細かなアニメーションに関する補足
今回のサンプルではメインの部分ではありませんが、細かな部分についてもアニメーションを加えている箇所があるので、その部分に関しても簡単に紹介します。
★6-1. ボタンを押下した際にTouchUp・TouchDown時のアニメーションを加えるTips:
ボタンの「押した感じを出す」ためにTouchUp時とTouchDown時のTargetをそれぞれ設定し、ボタンを押下時したことを強調するような表現を入れています。
- ボタンがTouchDownした時: → UIButtonの親になるUIViewの比率を縮小させる(さらにTouchUpInside・TouchUpOutside時でも処理を分ける)
- ボタンがTouchUpした時: → UIButtonの親になるUIViewの比率を元に戻し、完了したタイミングで処理を実行
という感じにするために、下記のような形の構成にしておきます。
補足としてUIButtonの親になるUIViewに画像の背景を設定したい際には、buttonWrappedView.backgroundColor = UIColor(patternImage: UIImage(named: "xxx")!)
とすると可能なので、実際に配置をする場合は、このボタンを内包しているUIViewにボタンの高さと幅の制約をつけて、配置したい位置の制約をつければOKです。
また、このサンプルでは下記のような形で設定されています。
import UIKit
class ArticleStoryTableViewCell: UITableViewCell {
//UI部品の配置
@IBOutlet weak private var articleStoryImageWrappedView: UIView!
@IBOutlet weak private var articleStoryButtonWrappedView: UIView!
@IBOutlet weak private var articleStoryButton: UIButton!
//ViewControllerへ処理内容を引き渡すためのクロージャー
var showStoryAction: (() -> ())?
//MARK: - Initializer
override func awakeFromNib() {
super.awakeFromNib()
setupArticleStoryTableViewCell()
}
//MARK: - Private Function
//入力ボタンのTouchDownのタイミングで実行される処理
@objc private func onTouchDownArticleStoryButton(sender: UIButton) {
UIView.animate(withDuration: 0.16, animations: {
self.articleStoryButtonWrappedView.transform = CGAffineTransform(scaleX: 0.94, y: 0.94)
}, completion: nil)
}
//入力ボタンのTouchUpInsideのタイミングで実行される処理
@objc private func onTouchUpInsideArticleStoryButton(sender: UIButton) {
UIView.animate(withDuration: 0.16, animations: {
self.articleStoryButtonWrappedView.transform = CGAffineTransform(scaleX: 1, y: 1)
}, completion: { finished in
//ViewController側でクロージャー内に設定した処理を実行する
self.showStoryAction?()
})
}
//入力ボタンのTouchUpOutsideのタイミングで実行される処理
@objc private func onTouchUpOutsideArticleStoryButton(sender: UIButton) {
UIView.animate(withDuration: 0.16, animations: {
self.articleStoryButtonWrappedView.transform = CGAffineTransform(scaleX: 1, y: 1)
}, completion: nil)
}
private func setupArticleStoryTableViewCell() {
//セルの装飾設定をする
self.accessoryType = .none
self.selectionStyle = .none
//写真付きサムネイル枠の装飾設定
articleStoryImageWrappedView.layer.masksToBounds = false
articleStoryImageWrappedView.layer.cornerRadius = 5.0
articleStoryImageWrappedView.layer.borderColor = UIColor.init(code: "dddddd").cgColor
articleStoryImageWrappedView.layer.borderWidth = 1
articleStoryImageWrappedView.layer.shadowRadius = 2.0
articleStoryImageWrappedView.layer.shadowOpacity = 0.15
articleStoryImageWrappedView.layer.shadowOffset = CGSize(width: 0, height: 1)
articleStoryImageWrappedView.layer.shadowColor = UIColor.black.cgColor
//ボタンの丸みをつける
articleStoryButtonWrappedView.layer.cornerRadius = 5.0
articleStoryButtonWrappedView.layer.masksToBounds = true
//ボタンアクションに関する設定
//TouchDown・TouchUpInside・TouchUpOutsideの時のイベントを設定する(完了時の具体的な処理はTouchUpInside側で設定すること)
articleStoryButton.addTarget(self, action: #selector(self.onTouchDownArticleStoryButton(sender:)), for: .touchDown)
articleStoryButton.addTarget(self, action: #selector(self.onTouchUpInsideArticleStoryButton(sender:)), for: .touchUpInside)
articleStoryButton.addTarget(self, action: #selector(self.onTouchUpOutsideArticleStoryButton(sender:)), for: .touchUpOutside)
}
}
★6-2. カウントダウンの様なアニメーションを加えるTips:
記事の詳細表示画面の「●●Views」及び「●●Likes」画面にも、カウントダウンのような動きをするアニメーションを折れています。
カウントダウンの様な動き及び、ドラムロールで回転する様なアニメーションや動きをするUILabelのサンプルに関しては下記のリポジトリのソースをご参考にしていただければと思います。
収録しているサンプルの概要:
ここで紹介した、2つのTipsは特にものすごく難しいTipsではありませんが、細かな部分のアニメーションが意外とユーザーの目を引くポイントになる場合もあるので、やりすぎない感じであれば良いアクセントになるはずです。(と私が思っているだけかも...)
★6-3. iOS13以降で変更を加えている部分についての補足:
細かな点になりますが、iOS13以降で改めて必要となった変更点についてのメモになります。
1. 従来通りのModal表示をするための追加対応 ※iOS13以上:
※ 特にカスタムトランジションを伴う部分でこの実装を忘れてしまうと、画面遷移に不具合が発生する場合があります。
//カスタムトランジションのプロトコルを適用させる
let navigationController = UINavigationController(rootViewController: storyPageViewController)
navigationController.transitioningDelegate = self
//Modalの画面遷移を実行する
//MEMO: iOS13以降のPresent/Dismiss時の調整
//Present/Dismissで実行するカスタムトランジションの場合ではこの設定を忘れると画面遷移がおかしくなるので注意
if #available(iOS 13.0, *) {
navigationController.modalPresentationStyle = .fullScreen
}
self.present(navigationController, animated: true, completion: nil)
2. UINavigationBarにおけるBackButton長押しの無効化 ※iOS14以上:
※ UIBarButtonItemを継承したクラスを用意し、長押しメニューのsetter部分を空にしてしまう形に変更します。
//UIViewControllerの拡張
extension UIViewController {
//戻るボタンの「戻る」テキストを削除した状態にするメソッド
func removeBackButtonText() {
let backButtonItem = BackBarButtonItem(title: "", style: .plain, target: nil, action: nil)
self.navigationController!.navigationBar.tintColor = UIColor.white
self.navigationItem.backBarButtonItem = backButtonItem
}
}
class BackBarButtonItem: UIBarButtonItem {
@available(iOS 14.0, *)
override var menu: UIMenu? {
set {
//MEMO: 長押しメニューを消去する
//Do Nothing.
}
get {
return super.menu
}
}
}
7. あとがき
今回のサンプルに関しては、アイコンフォント・データの非同期通信・画像のキャッシュの処理を行うもの以外は__「できるだけアニメーションや動きを伴う実装部分はDIYする」__というコンセプトで作成してみました。
また、今年から出たiPhoneXのSafeAreaに関する対応__(特にヘッダー画像とナビゲーションバーの動きの部分)についても、このサンプル内で色々と試行錯誤をすることができた点や、内部のデータとUIの関係性や振る舞いの設計(View - Presenter - Modelの構成)__にも考察ができた点は大きな収穫があったかなと個人的に感じています。
アニメーション部分の実装に関してはiOSアプリ開発のセオリーに若干則っていないカスタマイズを施している部分もあるかと思いますが、皆様のご参考になれば幸いに思います。
後編では、このサンプルでは「しばらくお待ちください」という旨のポップアップが出る部分に関する実装に関して解説できればと思いますので何卒よろしくお願い致します。