1. はじめに
「Swift愛好会 Advent Calendar 2016」の9日目を担当するfumiyasac(Fumiya Sakai)です。
最近ではUIまわりのこと「アニメーション・画面のデザインの構成に関すること・ライブラリ実装のDIY」に関するサンプルや登壇を行ったりもしています。
(Swift愛好会では「UI番長」の称号を頂き、照れるやら恥ずかしいやらですが…笑)
僕個人としては、EC系やメディア系のアプリをよく入れて使っているのですが、このようなアプリの多くでは「写真を生かしたレイアウトや心地よいアニメーション」がUIの随所に散りばめられていてなおかつ綺麗にまとまった形になっているものを多く見受けます。またそういうアプリのUIを見ると「自分でも真似をしてみたい!」という気持ちが湧いてくることも多かったように思います。
というわけで今回は自分の憧れや実装をしてみたいと思った部分を再現したUIを作成してみましたので、この実装に関する解説を行っていこうと思います。
また、要所要所でいつもながら恐縮ですが実装の際に書いたノートの内容も併せて掲載する形にしようと思います。
サンプルの全体的な動き:
- 2017.01.26: 追記
今回の記事のポイントとなる部分を「shibuya.swift #7」にて発表する機会がありましたので、その際に使用したスライドもここに共有致します。このスライドではカスタムトランジションの処理に関する部分を重点的に解説したものになりますので、皆様のご理解の参考となれば幸いに思います。
カスタムトランジションやアニメーションを活用した「写真を生かすUI」のサンプルの振り返り
※こちらのサンプルはPullRequestやIssue等はお気軽にどうぞ!
2. 今回の参考にした実装例とサンプル概要について
今回は参考にしたアプリは特にありませんが、以前に記事を見つけた際に是非この動きを活用してUIを作成してみたいと思った記事がありましたので、その記事を参考に自分なりに要所でアニメーションや機能を追加したり、レイアウトを変更するなどのUIまわりの要素をふんだんに取り入れた上で作成してみました。
★2-1. インスパイアを受けた参考記事:
アニメーションを交えた綺麗な遷移やカスタムトランジション関連に関しては深堀りしたいと感じていたこともあり、そして自分なりに実装の理屈を知った上でDIYしてみるのを以前から試してみたかったので、下記の2記事を元に今回は実装を行ってみました。
※今回は「画像がズームインしながら画面遷移するSwiftライブラリを公開しました」の記事内で紹介されているサンプルを元にSwift3.0.1に合わせて書き直した上で、
- SwiftyJSON & Alamofireを利用したAPI通信
- カスタムしたポップアップの表示
- アニメーションとスレッド・画像キャッシュをうまく活用した画像読み込み
- UINavigationControllerのデザインカスタマイズと設定に関する部分
- UITextView部分のHTMLタグの有効化
- ボタンに設定した下線が押下のタイミングで文字幅に合わせてた位置に移動
というような、実際のアプリ開発の中でもよくありそうなポピュラーなものからUI実装に一味を加える実装までを一通り加えたようなサンプルを作成してみました。
(せっかくお世話になっているSwift愛好会のAdventCalendarなので自分が盛り込みたい要素をありったけ入れました笑)
インスパイアを受けた参考記事:
どちらの記事も非常に丁寧かつわかりやすく、遷移処理のポイントや「なぜそうなるのか」という部分がとてもよくまとめられているので非常に参考になりました。この場をお借りして感謝の意を述べたいと思います。本当にありがとうございますm(_ _)m
★2-2. 今回のサンプルについて:
サンプルのキャプチャ画像その1:
サンプルのキャプチャ画像その2:
環境やバージョンについて:
- Xcode8.1
- Swift3.0.1
- MacOS X El Capitan (Ver10.11.6)
まだOSの方はSierraにしていなくってすみません…
使用ライブラリ:
HTTPS通信で自作のJSONデータを返すAPI(タイトル・カテゴリ・画像を一覧で取得するだけもの)からUICollectionViewのセル内に配置する際の処理には下記のライブラリを使用しました。またJSONの画像URLから画像のキャッシュさせて読み込みの高速化を図るためのライブラリも併せて活用しています。
| ライブラリ名 | ライブラリの機能概要 |
|:-----------|:------------|:------------|
|SVProgressHUD |データ読み込み時のプリローダー表示 |
|SwiftyJSON |JSONデータの解析をしやすくする |
|Alamofire |HTTPないしはHTTPSのネットワーク通信用 |
|SDWebImage |画像URLからの非同期での画像表示とキャッシュサポート |
Podfile内の設定は下記のようになります。(SVProgressHUDに関してはCocoaPods経由ではなく該当リポジトリのmasterブランチから直接取得をしています)
platform :ios, '9.0'
swift_version = '3.0'
target 'MediaStyleTableView' do
use_frameworks!
pod 'SVProgressHUD', :git => 'https://github.com/SVProgressHUD/SVProgressHUD.git'
pod 'SDWebImage'
pod 'Alamofire'
pod 'SwiftyJSON'
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['SWIFT_VERSION'] = '3.0'
end
end
end
end
AlamofireやSwiftyJSONでAPIからJSONを取得する部分に関しましては、アプリ開発においては定番の処理にはなるので、今回は詳しい解説については割愛しますが、ご参考までに本サンプルでは下記のように実装しています。
※ APIに関しては簡単な管理ツールとAWS S3への画像アップロード機能を提供しているだけものをRails4.1.7で作成しています(このリポジトリに入れてあります)。
//Alamofire & SwiftyJSONを利用してAPIからデータを取得する
fileprivate func getPhotoArticleData() {
//モデルデータを空にしてプログレスバーを表示する
models.removeAll()
SVProgressHUD.show(withStatus: "読み込み中...")
//データ取得処理開始時はcollectionViewのタッチイベントを無効にする
articleCollectionView.isUserInteractionEnabled = false
//データ取得処理開始時は同様に左右のBarButtonItemも非活性にする
self.navigationItem.leftBarButtonItem?.isEnabled = false
self.navigationItem.rightBarButtonItem?.isEnabled = false
Alamofire.request(“JSON取得用APIのエンドポイント”).responseJSON { (responseData) -> Void in
if let response = responseData.result.value {
//JSONデータ取得する
let jsonList = JSON(response)
//JSONから取得したデータを解析してモデルに追加する
if let results = jsonList["article"]["contents"].arrayObject {
let resultLists = results as! [[String : AnyObject]]
for i in 0...(resultLists.count - 1) {
//取得結果をDictionary型へ変換する
let result = resultLists[i] as Dictionary
//セルで使用する値の一覧を取得する
let title = result["title"] as! String
let image_url = result["image_url"] as! String
let category = result["category"] as! String
let color = CategoryName.getCategoryColor(category: category)
//モデルクラスのデータに順次追加をしていく
self.models.append(
KanazawaPhotoArticle(
mainTitle: title,
mainImage: image_url,
categoryName: CategoryName(rawValue: category)!,
themeColor: WebColorList(rawValue: color)!
)
)
}
}
//JSONからデータを取得しデータのセットが完了したらプログレスバーを消す(今回は0になることはないが本来は考慮はすべき)
SVProgressHUD.dismiss()
if self.models.count > 0 {
self.articleCollectionView.reloadData()
}
} else {
//エラーのハンドリングを行う
SVProgressHUD.dismiss()
let errorAlert = UIAlertController(
title: "通信状態エラー",
message: "データの取得に失敗しました。通信状態の良い場所ないしはお持ちのWiftに接続した状態で再度更新ボタンを押してお試し下さい。",
preferredStyle: UIAlertControllerStyle.alert
)
errorAlert.addAction(
UIAlertAction(
title: "OK",
style: UIAlertActionStyle.default,
handler: nil
)
)
self.present(errorAlert, animated: true, completion: nil)
}
//データ取得が終了したらcollectionViewのタッチイベントを有効にする
self.articleCollectionView.isUserInteractionEnabled = true
//データ取得が終了したら同様に左右のBarButtonItemも活性にする
self.navigationItem.leftBarButtonItem?.isEnabled = true
self.navigationItem.rightBarButtonItem?.isEnabled = true
}
}
今回の処理の中では取得したデータを別のデータ格納用Modelクラス(KanazawaPhotoArticle.swiftで定義したクラス)を経由して該当データを格納するような形にしています。また読み込み中はデータを表示しているUICollectionViewのタッチイベントを受け取らないようにしています。
3. UIViewControllerAnimatedTransitioningを活用して画像を拡大させて遷移先のUIViewControllerを表示する処理に関する実装ポイント
今回アニメーションに関しては、UICollectionViewの配置画像を詳細画面遷移時に拡大をさせて画面の一番上に表示させるような形になります。
StoryBoardの使い方や各種アニメーションに関するController及び表示用のViewControllerの設計はできるだけそのまま生かした状態で、Swift3に書き直しを行いました。
インスパイアされた前述の記事がすばらしい内容だったので、ここでは実装の上で自分なりに「ここは重要かも」と感じた部分をかいつまんで説明できればと思います。
★3-1. 基本を理解するのに役に立った参考資料と簡単な解説
独自の画面遷移を作成するにあたり、重要となるのは下記の3つのプロトコルになります。
- UIViewControllerAnimatedTransitioning(アニメーション用のコントローラー)
- UIViewControllerContextTransitioning(画面遷移のコンテキスト ※遷移先・遷移元の情報が入っている)
- UIViewControllerTransitioningDelegate(画面遷移のデリゲート)
また画面遷移のカスタマイズ方法に関しての基本的な理解をする際には、下記の情報がとても参考になりました。
基本的には下記のような形で、__「画面遷移のアニメーションの処理の実体と秒数をNSObjectを継承したUIViewControllerAnimatedTransitioningを採用したクラス」__を実装する形になります。
//画面遷移時に使用するアニメーションの実装をUIViewControllerAnimatedTransitioningを採用したクラスにて行う
class TransitionController: NSObject, UIViewControllerAnimatedTransitioning {
・・・(省略)・・・
//アニメーションの時間を定義する
internal func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return (アニメーションをする秒数)
}
/**
* アニメーションの実装を定義する
* この場合には画面遷移コンテキスト(UIViewControllerContextTransitioningを採用したオブジェクト)
* → 遷移元や遷移先のViewControllerやそのほか関連する情報が格納されているもの
*/
internal func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
(この中に画面遷移時のアニメーションを行う実体の処理を記載する)
}
・・・(省略)・・・
}
またanimateTransition(using transitionContext: UIViewControllerContextTransitioning)
メソッド内で画面遷移時のアニメーションを行う処理に関しましては、下記の図のように画像遷移コンテキストからアニメーションの実体となるContainerViewを作成して、その中に遷移元ないしは遷移先のViewControllerを中に追加する形になります。
この中に__「遷移先から遷移元」ないしは「遷移元から遷移先」__の各アニメーション処理を下記の図のようなイメージで記載していく形となります。
一見すると、クラスの記述がかなり今回は多いのではじめは少し驚くかとは思いますが、
- NSObjectを継承したUIViewControllerAnimatedTransitioningを採用したクラスを使用する
-
transitionDuration(using transitionContext: UIViewControllerContextTransitioning?)
メソッドで時間を定義する -
animateTransition(using transitionContext: UIViewControllerContextTransitioning)
メソッドで画面遷移の内容を記述する - 画像遷移コンテキストからアニメーションの実体となるContainerViewを作成してその中に画面遷移対象のViewControllerを入れる
- 遷移先から遷移元とその逆のアニメーションを
animateTransition(using transitionContext: UIViewControllerContextTransitioning)
メソッドに記載する
という5つのポイントを踏まえた上で実装を行っていくとより理解が深まるのではないかと思います。
★3-2. TransitionController.swiftの役割と実装のポイント
上記を踏まえて、遷移先から遷移元とその逆のアニメーションをanimateTransition(using transitionContext: UIViewControllerContextTransitioning)
メソッドに実装をしていきます。
今回の実装においては、
- 進むとき(Push):UICollectionViewの画像が拡大されて上に表示 & その他の部分はフェードインで表示
- 戻るとき(Pop):UICollectionViewの画像が縮小されて元の位置に戻る & その他の部分はフェードアウトで表示
というような遷移になります。それぞれの遷移イメージを図解にしたものが下記になりますのでご参考になれば幸いです。
参考1. 平面図:
参考2. 展開図:
また、ViewController.swift(詳細画面)
とDetailController.swift(詳細画面)
にはUIViewControllerContextTransitioningで設定したアニメーション関連処理の際に使用するcreateImageView()
メソッドを仕込んでおくことで、
- 画面遷移を行うメソッドが実行されるタイミングでUICollectionViewCellのタップで選択した画像を作成する
- 画面遷移のアニメーションが行われるタイミングで作成した画像の大きさに関するアニメーションを行う
という2つの部分がこの動きを実現する上でのポイントになるかと思います。
※詳細画面には、画面遷移が終了して表示される画像の大きさと同じ大きさのUIImageView(@IBOutlet weak fileprivate var targetImageView: UIImageView!
)を入れておくことを忘れないようにして下さい。
以上のポイントを踏まえて、Push時とPop時の画面遷移時の実装を行ったコードは下記のようになります。(量が多くなってしまったのでそれぞれfileprivateのメソッドに切り出しています。)
1. Push時の(ViewController.swift→DetailController.swift)画面遷移に関する処理
//Push時のアニメーションを実行するメソッド(引数は画面遷移時のコンテキスト)
fileprivate func forwardTransition(_ transitionContext: UIViewControllerContextTransitioning) {
//コンテキストを元にViewControllerのインスタンスを取得する(存在しない場合は処理を終了)
guard let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from), let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) else {
return
}
//アニメーションの実態となるコンテナビューを作成
let containerView = transitionContext.containerView
//遷移先のviewをaddSubviewする(fromVC.viewは最初からcontainerViewがsubviewとして持っている)
containerView.addSubview(toVC.view)
//addSubviewでレイアウトが崩れるため再レイアウトする
toVC.view.layoutIfNeeded()
//アニメーション用のimageViewを新しく作成する(遷移元及び遷移先と一緒に行う)
guard let sourceImageView = (fromVC as? ViewController)?.createImageView() else {
return
}
guard let destinationImageView = (toVC as? DetailController)?.createImageView() else {
return
}
//遷移先のimageViewをaddSubviewする
containerView.addSubview(sourceImageView)
//遷移先のアルファ値を0にしておく
toVC.view.alpha = 0.0
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0.05, options: UIViewAnimationOptions(), animations: {
//アニメーションを開始し、遷移先のimageViewのframeとcontetModeを遷移元のimageViewに代入する
sourceImageView.frame = destinationImageView.frame
sourceImageView.contentMode = destinationImageView.contentMode
//遷移元に配置したCollectionView内にあるcellのimageViewを非表示にする
(fromVC as? ViewController)?.selectedImageView?.isHidden = true
//遷移先のアルファ値を1に変更する
toVC.view.alpha = 1.0
}, completion: { finished in
//アニメーションを終了する
transitionContext.completeTransition(true)
})
}
2. Pop時の(DetailController.swift→ViewController.swift)画面遷移に関する処理
//Pop時のアニメーションを実行するメソッド(引数は画面遷移時のコンテキスト)
fileprivate func backwardTransition(_ transitionContext: UIViewControllerContextTransitioning) {
//コンテキストを元にViewControllerのインスタンスを取得する(存在しない場合は処理を終了)※Pushと逆のアニメーション
guard let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from), let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) else {
return
}
let containerView = transitionContext.containerView
//前回遷移したさいのImageViewが残っているので一度全てを外す
//(参考) https://github.com/SatoTakeshiX/SwiftSharedViewTransition
containerView.subviews.forEach { view in
view.removeFromSuperview()
}
/**
* アニメーションの実態となるコンテナビューを作成
* 最初からcontainerViewがsubviewとして持っているfromVC.viewを削除
*/
fromVC.view.removeFromSuperview()
//遷移先のviewをaddSubviewする(toView -> fromViewの順にaddSubview)
containerView.addSubview(toVC.view)
containerView.addSubview(fromVC.view)
guard let sourceImageView = (fromVC as? DetailController)?.createImageView() else {
return
}
guard let destinationImageView = (toVC as? ViewController)?.createImageView() else {
return
}
//遷移元のimageViewをaddSubviewする
containerView.addSubview(sourceImageView)
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0.05, options: UIViewAnimationOptions(), animations: {
//アニメーションを開始し、画像を遷移元のimageViewのframeに合わせて遷移元のアルファを0にする
sourceImageView.frame = destinationImageView.frame
fromVC.view.alpha = 0.0
}, completion: { finished in
//遷移元のimageViewを見えない状態にして、遷移先のimageViewを見える状態にする
sourceImageView.isHidden = true
(toVC as? ViewController)?.selectedImageView?.isHidden = false
//遷移元のimageViewと遷移元のviewController本体を削除する
//(参考) https://github.com/SatoTakeshiX/SwiftSharedViewTransition
sourceImageView.removeFromSuperview()
fromVC.view.removeFromSuperview()
//アニメーションを終了する
transitionContext.completeTransition(true)
})
}
※この遷移を行う際にはSwift3ではアニメーションの実体となるContainerViewの作成タイミングの記述が、前のバージョンと少し変わっているので注意が必要です。この部分については下記のリンクを参考に書き換えを行いました。
★3-3. TransitionNavigation.swiftの役割と実装のポイント
NavigationControllerはPush(進む)とPop(戻る)の2つの遷移を持っているので、この遷移に対してTransitionController.swift
で定義したアニメーションを適用させるために、UINavigationControllerクラスを拡張した新たなクラスを作成します。
class TransitionNavigationController: UINavigationController, UINavigationControllerDelegate {
override func viewDidLoad() {
super.viewDidLoad()
//UINavigationControllerのデリゲートを付与する
self.delegate = self
}
//UINavigationControllerでUIViewControllerAnimatedTransitioningを実装した独自アニメーションを使用する際に使用する
internal func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
//TransitionControllerのインスタンスを作成する
let transitionController = TransitionController()
//操作のenum(TransitionController内で定義)の値に応じてpushかpopかを決定する
switch operation {
case .push:
transitionController.forward = true
return transitionController
case .pop:
transitionController.forward = false
return transitionController
default:
break
}
return nil
}
}
また今回のようにNavigationControllerに独自に実装したアニメーションのクラスを適用したい場合には、navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController)
メソッドを使用するのもポイントになります。
実装のヒントや理解の手助けになりそうなリンク:
★3-4. 今回の実装で得た感想と知見について
UIViewControllerAnimatedTransitioningプロトコルを活用した、画面遷移のアニメーションをうまく活用することで、画面の切り替え時の動きに彩りを添えることができて、今後ともこの表現に関してはサンプルも交えながら深堀りしたいなと感じた次第です。
今回の実装については、iPhoneのカメラ機能やTwitterの画像表示の際の画面遷移のアニメーションを応用した形ではありましたが、UIとうまく調和する動きの様々な表現パターンをGithubやQiitaのアウトプットの中に今後とも、積極的に取り入れていきたいと思います。
実装のヒントや理解の手助けになりそうなリンク:
※こちらの参考リンクに関しては、英語のものにはなるのですがコードによる解説もありますので、適宜Swift3に置き換えて読み進めて頂くとさらに理解の助けになるかと思いますのでご活用頂ければ幸いに思います。
4. NavigationControllerのカスタマイズと遷移時の表示に関する工夫に関するポイント
今回はUIViewControllerContextTransitioningで設定したアニメーションにて、UICollectionViewに配置した画像を遷移先のページにて、上からいっぱいに表示させるような形をとっています。
StoryboardがそれぞれのViewControllerに対して1:1で分割されているので、コードを用いたUINavigationBarの遷移と今回で使用したカスタマイズについて解説をしていきます。
★4-1. UINavigationBarのスタイルに関するカスタマイズを行う
今回はコードでNavigationBarに関する設定を行う形になるのですが、具体的な要件としては下記のようになります。
- NavigationBarの戻るボタンは画像で設定する
- NavigationBarの背景は透明にする ※ 見た目の部分はNavigationBarもどきのUIViewを設置する
- 写真一覧(
ViewController.swift
)から詳細画面(DetailController.swift
)に遷移した際は前ページのタイトルは出さない
まずはAppDelegate.swift
にどのStoryboardに配置されているViewControllerを一番最初に呼び出すかの処理の後に、NavigationBarの戻るボタンに関する設定を行います。
※ 今回はデフォルトの矢印と同じような画像を用意しています。
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
/**
* Navigation部分は今回はコードで配置(Transitionの設定をコードで行うため)
* 使用するStoryboard名とViewControllerに設定したIdentifierの値を元に一番最初に表示するViewControllerを取得する
*/
self.window = UIWindow(frame: UIScreen.main.bounds)
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let controller = storyboard.instantiateViewController(withIdentifier: "ViewController") as! ViewController
//UINavigationBarの戻るボタンのカスタマイズ(タイトルが出てしまうので画像に置換する)
UINavigationBar.appearance().backIndicatorImage = UIImage(named: "arrow-icon")?.withRenderingMode(UIImageRenderingMode.alwaysOriginal)
UINavigationBar.appearance().backIndicatorTransitionMaskImage = UIImage(named: "arrow-icon")?.withRenderingMode(UIImageRenderingMode.alwaysOriginal)
・・・(省略)・・・
}
また、写真の一覧画面及び遷移先の画面に関しても同様にNavigationBarの背景を透明にしておくようにします。
//画面表示が開始された際のライフサイクル ※DetailController.swiftも同様
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
//NavigationControllerのカスタマイズを行う(ナビゲーションを透明にする)
self.navigationController!.navigationBar.setBackgroundImage(UIImage(), for: .default)
self.navigationController!.navigationBar.shadowImage = UIImage()
self.navigationController!.navigationBar.tintColor = UIColor.white
}
これでNavigationBarを透過する処理とNavigationControllerに関する初期設定が完了しました。今回はStoryboardの分割した形になっていたこともあり最初は実装に戸惑った部分もありましたが、下記のリンクを参考にSwift3.0系の実装の形式に直す形にしました。
参考リンク:
※ 実装の際には必要に応じて適宜Swift3.0系で置き換えてください。
- UINavigationBarの戻るボタンのカスタマイズまとめ
- UINavigationBar custom back button without title
- 【Swift】NavigationBarの設定。バーの色や背景画像をカスタマイズする。
★4-2. 写真の一覧表示画面では透明なナビゲーションバーの直下にダミーのNavigationBarもどきを作成する
前の部分でNavigationBarの背景色を透明化と戻るボタンに関する設定を行いましたが、写真一覧(ViewController.swift
)画面ではナビゲーションバーと同じような作りにしたかったことや、UIBarButtonItemを使いたかったので、結果的に若干不自然な作りにはなってしまいましたが、下記のようにダミーのUIViewを作成して実装を行いました。
本サンプルのヘッダー部分の各々のUI要素の重なり順に関しては、下記のような図のように重なる実装を行っています。
navigationItem.title
の値を入れてしまうと詳細画面(DetailController.swift
)に遷移した際に矢印の右側にタイトルが表示されてしまうので、タイトルはダミーのUIViewの中に入れる形にし、更新ボタンや特集ボタン(今回はポップアップ表示)についてはUIBarbuttonItemで実装しています。
実際のコードは下記のようになります。
override func viewDidLoad() {
super.viewDidLoad()
//StatusBar & NavigationBarの上書き用の背景を設定
initializeDummyHeaderView()
self.view.addSubview(headerBackgroundView)
・・・(省略)・・・
//タイトルの設定を空文字にする(NavigationControllerで引き継がれるのを防止する)
navigationItem.title = ""
//左メニューボタンの配置(※今回はあくまでデザイン上の仮置き)
let leftMenuButton = UIBarButtonItem(title: "💫更新", style: .plain, target: self, action: #selector(ViewController.reloadButtonTapped(button:)))
leftMenuButton.setTitleTextAttributes(attrsButton, for: .normal)
navigationItem.leftBarButtonItem = leftMenuButton
//右メニューボタンの配置(※今回はあくまでデザイン上の仮置き)
let rightMenuButton = UIBarButtonItem(title: "🔖特集", style: .plain, target: self, action: #selector(ViewController.pickupButtonTapped(button:)))
rightMenuButton.setTitleTextAttributes(attrsButton, for: .normal)
navigationItem.rightBarButtonItem = rightMenuButton
・・・(省略)・・・
}
//ダミー用のヘッダービューの内容を設定する
fileprivate func initializeDummyHeaderView() {
//背景の配色や線に関する設定を行う
headerBackgroundView.backgroundColor = UIColor.white
headerBackgroundView.layer.borderWidth = 1
headerBackgroundView.layer.borderColor = WebColorConverter.colorWithHexString(hex: WebColorList.lightGrayCode.rawValue).cgColor
//タイトルのラベルを作成してダミーのヘッダービューに追加する
let dummyTitle: UILabel! = UILabel()
dummyTitle.font = UIFont(name: "Georgia-Bold", size: 14)!
dummyTitle.text = "石川の写真周遊録"
dummyTitle.textColor = UIColor.black
dummyTitle.textAlignment = NSTextAlignment.center
dummyTitle.frame = CGRect(x: 0, y: 20, width: UIScreen.main.bounds.size.width, height: 44)
headerBackgroundView.addSubview(dummyTitle)
}
本サンプルでは結果的に少し不自然な感じの実装にはなりますが、実際にアプリを開発する場合等においてはNavigationControllerの特性や仕様に関しては公式のドキュメント等を参考にし、その上でいかに不自然な実装にならずに画面のデザインと合わせた作りにするかという部分がポイントになってくると思います。
★4-3. 写真一覧のスクロール方向に応じて上のナビゲーションバーを隠す処理
UICollectionViewもUITableViewと同様にUIScrollViewDelegateを持っているので、scrollViewDidScrollメソッド
でスクロールの方向を取得してその上でナビゲーションバーを隠す処理を入れています。
今回はダミーのUIViewでナビゲーションバーもどきを作成しているので、その部分も一緒にアニメーションをさせるようにする必要があります。(ナビゲーションバーに入れたUIBarButtonItemについては隠すタイミングでnilを代入して対処しました)
//スクロールが検知された時に実行される処理
internal func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
//下方向のスクロールの際にはダミーのスクロールビューを隠す(逆の場合は表示する)
if offsetY < 0 {
UIView.animate(withDuration: 0.16, delay: 0, options: UIViewAnimationOptions.curveEaseOut, animations: {
//ダミーのナビゲーションバーを表示する
self.headerBackgroundView.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.size.width, height: 64)
}, completion: { finished in
//アニメーション完了時にナビゲーションバーのボタンを再配置する
let leftMenuButton = UIBarButtonItem(title: "💫更新", style: .plain, target: self, action: #selector(ViewController.reloadButtonTapped(button:)))
leftMenuButton.setTitleTextAttributes(self.attrsButton, for: .normal)
self.navigationItem.leftBarButtonItem = leftMenuButton
let rightMenuButton = UIBarButtonItem(title: "🔖特集", style: .plain, target: self, action: #selector(ViewController.pickupButtonTapped(button:)))
rightMenuButton.setTitleTextAttributes(self.attrsButton, for: .normal)
self.navigationItem.rightBarButtonItem = rightMenuButton
})
} else {
UIView.animate(withDuration: 0.16, delay: 0, options: UIViewAnimationOptions.curveEaseOut, animations: {
//ダミーのナビゲーションバーを隠す
self.headerBackgroundView.frame = CGRect(x: 0, y: -64, width: UIScreen.main.bounds.size.width, height: 64)
}, completion: { finished in
//アニメーション完了時にナビゲーションバーのボタンにnilを入れて空っぽの状態する
self.navigationItem.leftBarButtonItem = nil
self.navigationItem.rightBarButtonItem = nil
})
}
}
今回のサンプルでは、NavigationBarのコントロール部分が想像以上に苦労した部分でした。NavigationControllerのプロパティや仕様に関する部分については、一見するとすぐにカスタマイズできそうな部分がそうでもなかったりする部分があるので確認をしておかなければと強く感じました。
5. スクロールの速さが速い際のコンテンツのフェードイン処理とSDWebImageの処理に関するポイント
画像のURLから画像を読み込んでセル内のUIImageViewに表示させる際には、画像の取得処理をスレッドを用いて非同期で行いますが、やはり通信状態よろしくない場合や画像が大きな場合(今回は640×640の画像を使っています)においてはスクロール時にカクツキが生じたり、前の画像が残ってしまったりすることがあるので、その対策としてこのサンプル内では、画像のキャッシュとスレッドを利用したアニメーションを利用して表示を行うようにしました。
画像のキャッシュに関しては定番のSDWebImageを利用しています。
※こちらは導入の際にはBridging-Headerファイルを忘れないように。
また導入の際には、下記の動画解説を参考にして導入しました。(英語の解説動画にはなりますが導入方法から基本的な使い方をコード付きで解説しているので参考になりました)
本サンプルでの処理の順番をまとめると、
- まずは画像のURL(絶対パス)を取得する
- サブスレッドで画像をSDWebImageで提供している
sd_setImage(with: ‘画像のURL’)
メソッドを利用して画像を読み込み、セルに表示しているもののアルファを0にする - メインスレッドに戻ってきたタイミングで、セルに表示しているもののアルファを1にする
といった形で処理を記述し、スクロールでコンテンツが表示エリアに入ったタイミングでふわりとフェードインをするような表現をしています。
//セルに表示する値を設定する
internal func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ArticleCell", for: indexPath) as! ArticleCell
・・・(省略)・・・
//画像URLを取得する
let image_url = URL(string: models[indexPath.row].mainImage)
//表示時にフェードインするようなアニメーションをかける
DispatchQueue.global().async {
//画像をURLから読み込んでキャッシュさせる場合などはここに記載(サブスレッド)
cell.cellImageView.sd_setImage(with: image_url)
//表示するUIパーツは非表示にする
cell.cellImageView.alpha = 0
cell.titleLabel.alpha = 0
cell.categoryLabel.alpha = 0
//画面の更新はメインスレッドで行う
DispatchQueue.main.async {
//画像の準備が完了したらUIImageViewを表示する
UIView.animate(withDuration: 0.64, delay: 0.26, options: UIViewAnimationOptions.curveEaseOut, animations:{
cell.cellImageView.alpha = 1
cell.titleLabel.alpha = 1
cell.categoryLabel.alpha = 1
}, completion: nil)
}
}
return cell
}
このサンプルをインストールして一番最初に表示する際は、アニメーションのタイミングとのズレが出る場合がありますが、一度キャッシュがかかればスクロールを早く動かした場合でも、アニメーションを伴って出てくるので、見た目にも綺麗でなおかつキャッシュもかけているので、特にスクロール早く前の画像を残さないようにする対策(というかごまかし?)にも結果的にはなりました。
※ この部分については実装プロセスの中で偶然に発見した方法なので、ちょっと実装には自信がないのですが、もしもっと良い実装方法をご存知な方がいましたらご教授願いますm(_ _)m
6. UIScrollView内に配置したボタンを押すと文字幅に合わせて下線のラベルが動く処理に関するポイント
この部分の挙動に関しても、他のアプリでも見かけることがよくあるUIで個人的にも気になっていた部分ではあったので今回はUICollectionView内の写真コンテンツタップ後に遷移する詳細ページコンテンツの下部分のナビゲーションに適用をしてみました。大枠の部分に関しては、 「UIScrollViewを作成し、ViewDidLayoutSubViews内でその中にボタンを配置する」 という流れが基本にはなりますが、今回の挙動ではさらに、
- ボタンの下にある下線ラベルは、ボタンの文字幅と同じ長さ & タップされたボタンの部分まで移動する
- ボタンを押すと、押したボタンのちょうど真下の位置に来る & タップされたボタンの文字と同じ幅になる
という部分を考慮しなくてはいけません。今回はまず「左・真ん中・右」の3つのボタンをUIScrollViewに配置しボタンの文字に合わせて下線(動くUILabel)を設定する部分の解説をしていければと思います。
★6-1. ボタンに入れたテキストの幅とフォントの種類を元にテキスト幅を算出する
ボタン内に表示しているテキストと使用したフォントから文字の幅を算出する際にはstring.size(attributes: [NSFontAttributeName : font])
とすることで、対象のテキストの幅と高さを取得することができます。今回は__「テキスト(String型)とフォント種類(UIFont型)を引数にとり、文字列幅(Int型)を返す」__fileprivateのメソッドにして、初期配置時とボタンタップ時の処理の際に使用する形にしています。
//取得したテキスト文字列とフォントから文字列の幅を取得する
fileprivate func getCharacterWidthValue(string: String, font: UIFont) -> Int {
let size = string.size(attributes: [NSFontAttributeName : font])
return Int(size.width)
}
文字幅や高さを元に位置を決めたり、今回のような実装を行う際には知っておくと便利かなと思います。(この部分は僕もよく忘れてしまう部分です)
参考リンク:
※ 実装の際には必要に応じて適宜Swift3.0系で置き換えてください。
★6-2. UIScrollView内のボタン押下時に下線(動くラベル)の位置調整を行う計算
上記で解説した処理とボタンを配置した際に設定したscrollButtonTapped
メソッドの処理を組み合わせて、下記のようにアニメーションを交えて下線(動くラベル)の位置調整を行うように設定すれば、前述した要件を満たすような処理ができます。(もう少し下線位置を決めるメソッドに関してはリファクタリングの余地はありそうですが…)
//ボタンをタップした際に行われる処理
func scrollButtonTapped(button: UIButton) {
//押されたボタンのタグを取得
let page: Int = button.tag
//コンテンツを押されたボタンに応じて移動する
moveToCurrentButtonLabelButtonTapped(page: page)
}
//ボタンタップ時に動くラベルをスライドさせる
fileprivate func moveToCurrentButtonLabelButtonTapped(page: Int) {
UIView.animate(withDuration: 0.26, delay: 0, options: [], animations: {
//ボタンのテキスト幅を取得する
let buttonTextWidth = self.getCharacterWidthValue(
string: ScrollButtonList.buttonList[page],
font: UIFont(name: "Georgia-Bold", size: 11)!
)
//動くラベルのScrollView内でのX座標を取得する
let positionX = self.getMovingLabelPosX(
scrollViewLayoutWidth: Int(self.menuScrollView.frame.width),
separateValue: 3,
page: page,
charWidth: buttonTextWidth
)
//アニメーションで動くラベル(下線)の動かす位置を設定する
self.movingLabel.frame = CGRect(
x: positionX,
y: SlideMenuSetting.movingLabelY,
width: buttonTextWidth,
height: SlideMenuSetting.movingLabelH
)
}, completion: nil)
}
//ボタン表示テキストとスクロールビューの表示エリアから動くラベル(下線)のX座標を取得する
/**
* 引数は下記の通り:
* scrollViewLayoutWidth(Int型) : ボタンを入れたスクロールビューの幅
* separateValue(Int型) : ボタンを入れたスクロールビューの幅で表示されるボタンの数
* page(Int型) : 現在のページ番号(0..n)
* charWidth(Int型) : ボタンに表示している文字の幅
*/
fileprivate func getMovingLabelPosX(scrollViewLayoutWidth: Int, separateValue: Int, page: Int, charWidth: Int) -> Int {
/**
* 下記のような計算式で位置を算出する:
* ★ (動くラベルのX座標位置) = (ボタンを入れたスクロールビューの幅 ÷ ボタン数 ÷ 2) + (ボタンを入れたスクロールビューの幅 ÷ ボタン数 × 現在のページ番号) - (ボタンに表示している文字の幅 ÷ 2)
*/
let positionX: Int = Int(scrollViewLayoutWidth / separateValue / 2) + Int(Int(scrollViewLayoutWidth / 3) * page) - Int(charWidth / 2)
return positionX
}
カテゴリーに応じて表示コンテンツの切り替えを行う必要があるメディア系やEC系のアプリのUIでは結構よく見かける表現なのですが、いざ自前で作成となると下線の位置調整の計算が面倒な部分がありました。ですが文字ベースのデザインの際には全体的にシンプルなものが多いので、シンプルながらもアニメーションと合わせてワンポイントとして活用してみても良いかと思います。
7. UITextView内のテキストでHTMLタグを有効化する際の処理に関するポイント
本サンプルで画面をタップした際に詳細画面に遷移した後のテキスト部分に関しては、HTMLタグを認識するようにしています。僕自身がもともとWebの人でもあったためか、簡単なHTMLが使用することで文字の装飾や改行や文字の強調などもできたりするのは地味にあると嬉しいところなのかな?と思ったりもしています。
★7-1. UITextViewのプロパティはtextではなくattributedTextとなる点がポイント
本サンプルにおいてはUITextViewの部分がtextプロパティではなくattributedTextプロパティになっている所がポイントになります。
対象の文字列データに関して、後述するStructファイル(ConvertHtmlText.swift)の中にテキストの行間の高さや文字コードの設定、HTMLを有効化する処理を行うメソッドが入っています。
このメソッドを該当セルのtextViewに受け取った文字列からHTMLを有効化した処理を通したテキストを入れるというような流れになります。
//テーブルビューのセル設定を行う
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ParagraphCell") as! ParagraphCell
//TODO: 自作APIまたはモックデータで表示を行う
cell.paragraphTitle.text = paragraphModels[indexPath.row].paragraphTitle
cell.paragraphDescription.text = paragraphModels[indexPath.row].paragraphSummary
cell.paragraphThumb.image = UIImage(named: paragraphModels[indexPath.row].thumbnail)
//JFYI: HTMLタグが混ざってしまう場合(コンテンツからのスクレイピング時など)はこちらを使う
cell.paragraphText.attributedText = ConvertHtmlText.activateHtmlTags(targetString: paragraphModels[indexPath.row].paragraphText)
//セルのアクセサリタイプの設定
cell.accessoryType = UITableViewCellAccessoryType.none
cell.selectionStyle = UITableViewCellSelectionStyle.none
return cell
}
NSAttributedTextにすることでHTMLタグやCSSのスタイル属性を適用することはできます。WebのようにCSSを柔軟に変更等を行うには少ししんどいかもしれませんが、文字等の簡単なCSSによる装飾程度であればある程度なら対応できるかなと思います。
★7-2. HTMLタグが混ざっているテキストのデータをHTMLに変換して表示する
受け取った文字列に対して色々と装飾を行う部分に関しては、別途Structとメソッドを新たに作成をしています。この中でやっていることは、
- 文字列の属性の決定 ※HTMLの有効化(変数:NSDocumentTypeDocumentAttribute)
- 文字コードの設定(変数:NSCharacterEncodingDocumentAttribute)
- テキストフィールドの行間の調整(NSParagraphStyleAttributeName)
になります。諸々の設定が終わった後に、NSAttributedString()
メソッドを用いて文字列を変換する処理を最後に行います。
(コード内に実装を行った際の参考リンクも併せて参照して頂ければと思います)
//HTMLタグを有効にするメソッド
static func activateHtmlTags(targetString: String) -> NSAttributedString {
//対象のテキスト(この中にはHTMLタグや簡単な直書きのCSSがあることを想定)
let htmlText: String = targetString
/**
* 行間を調節するにはNSAttributedString(またはNSMutableAttributedString)を使用する。
*
* (イメージ) CSSのline-heightのようなイメージ「line-height: 1.8;」
* http://easyramble.com/set-line-height-with-swift.html
*
* (参考)【iOS Swift入門 #120】UILabelで複数行の文字列を表示するときに行間を調節する
* http://swift-studying.com/blog/swift/?p=553
*/
let paragraph = NSMutableParagraphStyle()
paragraph.lineHeightMultiple = 2.0
//HTMLに対応した文字列に直す処理とオプションの設定を行う
let encodedData = htmlText.data(using: String.Encoding.utf8)!
let attributedOptions : [String : AnyObject] = [
NSDocumentTypeDocumentAttribute : NSHTMLTextDocumentType as AnyObject,
NSCharacterEncodingDocumentAttribute: NSNumber(value: String.Encoding.utf8.rawValue) as AnyObject,
NSParagraphStyleAttributeName : paragraph
]
let attributedString = try! NSAttributedString(data: encodedData, options: attributedOptions, documentAttributes: nil)
return attributedString
}
テキスト(UILabelないしはUITextView)の装飾に関する参考リンクは下記になりますので共有しておきます。
参考リンク:
※ 実装の際には必要に応じて適宜Swift3.0系で置き換えてください。
- NSAttributedString(Apple公式ドキュメント)
- テキストを装飾する/NSAttributedStringの属性一覧
- SwiftでNSAttributedStringを使って文字列を装飾する(UILabelに画像を表示する)
- NSAttributedStringを使ってみる
テキストの装飾して表示する部分に関しては、僕自身もふとした時になかなか思い出せなかったりする部分でもあるので、自分への備忘録も兼ねて掲載してみました。
8. カスタマイズしたポップアップコンテンツを表示する処理に関するポイント
ポップアップの実装に関しては、self.viewを透過した状態のViewControllerを重ねる実装方法やContainerViewを活用する等の方法がまずは思いつくかと思います。
今回は実装が一番容易でなおかつStoryboardが分割できるような形にも対応できるように「self.viewを透過した状態のViewControllerを重ねる実装」としました。
動きの参考にしたサンプル:
※ただしこの実装方法では、ポップアップ用のViewControllerを子のViewControllerとして設定するので、ポップアップ時でもNavigationBarのボタンが押せてしまう不具合が出るのでそのままでは使えませんでした。
★8-1. UIViewControllerのライフサイクルを活用してアニメーションを適用する
今回はポップアップが表示される時に、ポップアップを仕込んでいる側のViewController内では下記のような形でUIViewControllerのライフサイクルを利用して表示する形を取っています。
- viewDidLoad内:ポップアップのViewController(
PopupController.swift
)のアルファ値を0にする - viewDidAppear内:表示アニメーションを行うメソッド(
showAnimatePopup()
メソッド)を実行する
という形にしています。アニメーション処理の実体の部分は下記のようになります。今回は全体の拡大比率とアルファを変えたものにしています。
class PopupController: UIViewController {
・・・(省略)・・・
/* (Fileprivate functions) */
//ポップアップアニメーションを実行する(実行するまではアルファが0でこのUIViewControllerが拡大している状態)
fileprivate func showAnimatePopup() {
self.view.transform = CGAffineTransform(scaleX: 1.38, y: 1.38)
UIView.animate(withDuration: 0.16, animations: {
//おおもとのViewのアルファ値を1.0にして拡大比率を元に戻す
self.view.alpha = 1.0
self.view.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
})
}
//ポップアップアニメーションを閉じる(実行するまではアルファが1でこのUIViewControllerが等倍の状態)
fileprivate func removeAnimatePopup() {
UIView.animate(withDuration: 0.16, animations: {
//おおもとのViewのアルファ値を0.0にして拡大比率を拡大した状態に変更
self.view.transform = CGAffineTransform(scaleX: 1.38, y: 1.38)
self.view.alpha = 0.0
}, completion:{ finished in
//アニメーションが完了した際に遷移元に戻す(このクラスで独自アニメーションを定義しているので第1引数:animatedをfalseにしておく)
self.dismiss(animated: false, completion: nil)
})
}
・・・(省略)・・・
}
また遷移元からポップアップを呼び出す際は下記のようにします。この部分の設定はコードでもXCodeでもどちらでも対応していますので、プロジェクトでの使い方に応じて使い分けても良いかもしれません。
//ピックアップボタンタップ時のメソッド
func pickupButtonTapped(button: UIButton) {
//遷移元からポップアップ用のViewControllerのインスタンスを作成する
let popupVC = UIStoryboard(name: "Popup", bundle: nil).instantiateViewController(withIdentifier: "PopupController") as! PopupController
/**
* ポップアップ用のViewConrollerを設定し、modalPresentationStyle(= .overCurrentContext)と背景色(= UIColor.clear)を設定する
* 参考:XCode内で設定する場合は下記URLのように行うと一番簡単です
* http://qiita.com/dondoko-susumu/items/7b48413f63a771484fbe
*/
popupVC.modalPresentationStyle = .overCurrentContext
popupVC.view.backgroundColor = UIColor.clear
//ポップアップ用のViewControllerへ遷移(遷移元のクラスで独自アニメーションを定義しているので第1引数:animatedをfalseにしておく)
self.present(popupVC, animated: false, completion: nil)
}
ポップアップの用途としては、データ登録時の確認や広告表示をはじめ、ユーザーのアクションを促すための部分でもあるので、アニメーションを少し工夫するだけでも良い意味でユーザーの目を引くことがでっきるのかなと感じる部分ですね。(とはいってもやり過ぎないように注意は必要なのでそこは気をつけないとではありますが。)
9. あとがき
今回の処理で一番のポイントになる部分は、UICollectionViewで表示され写真の一覧表示から、画像の拡大を行いながらアニメーションを伴って画面遷移が行われる部分になるかと思います。特にUIViewControllerAnimatedTransitioningで自作した画面遷移時のアニメーションとUINavigationControllerを併せて使用する際には、アプリのデザインに併せてNavigationControllerのデザインの変更を行うような場合が出てくると思います。
この部分のカスタマイズを伴う実装を行う場合は、UINavigationControllerの特性や仕様を把握していないと、かなりハマりやすい部分ですのでAppleの公式ドキュメントやサンプル等も定期的に確認したり、Stackoverflow等を活用してハマりやすいポイントについて予習しておくと実装の際に役に立つかもしれないと個人的に感じました。
またアニメーションのタイミングや使い方に関しても、特に今回のように画面にかなり動きがあるようなUIに関してはアニメーションのタイミングや使い方にも、闇雲に入れて動かすのではなく画面遷移やユーザーの動き等も考慮に入れた上での実装を行うようにすると良いかと思います。
個人的な取り組みとしてはデザインやアニメーションが綺麗なアプリをインストールして挙動や実装の予想を立てたり、Cocoa Controlsやawesome-ios-ui等でライブラリの挙動をできるところまでDIYで再現してみたり等は時間を見つけて少しずつやっている感じです。
そしてアニメーションを実装する際のポイントや気をつけるべき点に関しては、下記の記事がとても参考になるかと思いますので、ぜひ一読頂ければ幸いです。
おまけ. Swift愛好会と僕
Swift愛好会との出会いは、僕が@jollyjoesterさんと以前に「講師(@jollyjoester)と生徒(僕)」であったことや、その後もちょくちょくと連絡を取り合ったり他の勉強会で顔を合わせたりする機会もあったことがきっかけでした。
また参加者の皆様や主催者の方々も「ゆるいながらも楽しむことを常に忘れない雰囲気」が本当に好きです。発表する際はいつも僕自身いい緊張感と楽しみやワクワク感を感じながらいます。登壇者ならびに参加者の方々もSwiftの中でもお互いに様々な得意領域を持ちながらも、お互いのことを認め合っている感じがあるところはこの勉強会の良さでなのかなと思います。
12/3〜12/4で開催されたSwift愛好会の合宿での一コマ:
今まではiOSアプリ開発を本職としていないながら(元デザイナーなサーバーサイドエンジニアでした)も、SwiftやiOSアプリ開発に対する熱量を常に忘れないでいれたのはこの勉強会のおかげだと思います。
これからも参加者の皆様が「このUIかっこいいから真似してみたい!」と思っていただける、そんな価値あるアウトプットを常に提供できるようなUI番長として、走り続けていきたいと思います!