Help us understand the problem. What is going on with this article?

SwiftでStoryboardとContainerViewを活用して、動きに合わせた細部のアレンジを加えたサンプル例(iOSで良くあるライブラリを参考にDIYしました)

More than 3 years have passed since last update.

1. はじめに

以前の記事にてStoryboardとContainerView等を活用してライブラリに近しい挙動を実現する記事を書きましたが、今回はその記事の補足に近いものになります。
下記3つの記事で解説を行っている実装サンプルに関しては、主にアプリの「土台」となる部分に関する挙動の制御や画面の設計に関する部分の実装に重点を置いたものを中心に取り扱ってきました。

今回では上記の記事での解説を踏まえた上で、その際のノウハウのエッセンスやを生かしつつ、さらに細かい部分の動きを加えることによってより、ユーザーの目を引きやすくかつタッチイベントやスクロールに関する処理やアニメーションの活用を深堀りしていこうと思います。

サンプルの全体的な動き:
ezgif.com-3ab9b553b0.gif

※こちらのサンプルはPullRequestやIssue等はお気軽にどうぞ!

今回のサンプルの目的としましては、ライブラリの完全再現というわけではありませんが、出来るだけ近しい形になるまでハードコーディングで再現を行ってみたサンプルになります。
様々なアプリでも良く見かける動きではあるものの、ライブラリを用いない実装をする場合はいかにして実装するか?という場合のヒント等になれば嬉しく思います。

追記:

  • 2016.11.25

Shinagawa.swift #1にてこの記事の内容に関して登壇を行ったので、その際の資料を共有致します。記事の内容が結構ゴツイのでいきなり読むのはちょっとという方は、下記に使用したスライドがありますのでご活用頂ければ幸いに思います。

ライブラリでよくある動きをUIKitのみでDIYしてみる(Part1)

2. 今回の参考アプリとサンプル概要について

今回は上記の記事でStoryboardとContainerViewを用いて土台を作り、その上要所となる部分に下記の3つのアクションを加えていきます。
(土台となる部分の実装に関しましては今回は割愛しますが、Githubのサンプルコードや上記の記事等を参考にして実装して頂ければ幸いに思います)

  1. 下に隠れているコンテンツ表示時の移動したメインコンテンツの動き
  2. 下スクロール時にナビゲーションを隠す動き(上スクロール時はその逆の動き)
  3. カテゴリー選択ボタン部分のスクロール & 中のボタンタップ時の位置補正

またそれぞれのトピックの解説の最後には動きの参考にしたライブラリもピックアップしていますので、そちらも併せてご確認頂ければと思います。

★2-1. 今回の参考アプリ:

今回のサンプルで参考にしたアプリは下記のアプリになります。

  • Facebook(下に隠れているコンテンツ表示時の移動したメインコンテンツの動き&下スクロール時にナビゲーションを隠す動き)
  • Locari(カテゴリー選択ボタン部分のスクロール&中のボタンタップ時の位置補正)

Facebookに関しては言うまでもありませんが、もう一方のLocariは女性向けのライフスタイル情報のアプリになります。アプリのUIもシンプルでかつ記事も読みやすい感じの印象を受けました。シンプルな中に気持ちの良いタッチイベントやスクロール等のアニメーションがあるだけでも、UIにグッと彩りが添えられる感じになるので、うまく活用できるようにもっともっと研究やショーケース製作をしたいと感じています。

★2-2. 今回のサンプルについて

今回のサンプルは割と動きの感じがわかりやすいように、画像をメインに持ってくるようなシンプルなUIデザインと併せて作成してみました。
メインコンテンツのUITableViewの部分はシンプルなデザインと相性が良さげな画像のParallax(視差効果)も加えています。

サンプルのキャプチャ画像その1:

sample_capture1.jpg

サンプルのキャプチャ画像その2:

sample_capture2.jpg

環境やバージョンについて:

  • Xcode8.0(Xcode8.1でもOKです)
  • Swift3.0
  • MacOS X El Capitan (Ver10.11.6)

※写真については写真AC様のフリー素材を使用しています。

※本サンプルはSwift3.0で作成していますが、Swift2.3ないしはそれ以前のバージョンをお使いの方は適宜メソッドの読み替えを行って実装して頂ければと思います。

余談としましては、特にナビゲーションの開閉処理に関しては結構細かい部分まで考慮するとなると、それぞれのViewの階層の関係性や位置関係等も考えた上で実装しなければいけなかったので意外と大変でした汗

3. TouchEventとGestureRecognizerを利用してドラッグに追従する開閉処理を作る実装ポイント

ドラッグに追従してメインコンテンツを表示しているContainerViewをドラッグさせて、下に隠れているメニュー部分のContainerViewを表示させる動きを表現する場合には、TouchEventとGestureRecognizerに関する理解と適切なタイミングでの活用がポイントになってきます。今回も自分でまとめたノートと併せて、解説を行っていきます。

★3-1. TouchEvent&GestureRecognizerの基本とisUserInteractionEnabledプロパティのおさらい

この実装を行う上でまず重要なのはGestureRecognizer及びTouchEventになりますので、まずは書き方のおさらいをしていければと思います。Swift3.0からでは微妙に以前のバージョンと記法が変わっているので注意が必要な部分でもあります。
また今回の実装については、コンテンツが閉じていてドラッグでコンテンツを開く場合(コンテンツのContainerViewのGestureRecognizerを活用)の処理とコンテンツが開いていてドラッグでコンテンツを閉じる場合(おおもとのself.viewのタッチイベントを活用)の処理を分けて考えていきます。

そしてもう1つ今回の開閉処理で重要なポイントとなるのが、それぞれのView要素のisUserIneractionEnabledプロパティのハンドリングになります。

このプロパティに関しての簡単な解説としては、

  1. 任意のViewに対してisUserIneractionEnabledプロパティがtrueの場合にはそのViewはタッチイベントを受け取ることができます。

  2. 一方で任意のViewに対してisUserIneractionEnabledプロパティがfalseの場合にはそのViewはタッチイベントの受け取り対象にはならなくなります(なのでContainerViewの接続先のViewControllerのイベントも受け取れない状態になる)。

TouchEvent&GestureRecognizerの処理と併せて、ViewControllerに重なっているContainerViewや透明ボタンに対してのisUserIneractionEnabledプロパティの値を状態によって切り替えることでタッチイベントのハンドリングも一緒に行っていきます。

初期状態ではメニューが隠れていてかつメインコンテンツを動かしたい状態にしておくので、

  • 透明ボタン(isUserIneractionEnabled = false) ※ここは装飾用なので常にfalse
  • メインコンテンツのContainerView(isUserIneractionEnabled = true)
  • サイドメニューのContainerView(isUserIneractionEnabled = false)

という形で設定しておきます。

(1)コンテンツが閉じていてドラッグでコンテンツを開く場合:

使用するサンプルは、「Storyboard上のおおもとのViewControllerに、下からサイドメニューのContainerViewメインコンテンツのContainerView装飾用の透明ボタン」という順番でUIパーツ要素を重ねています。またメインコンテンツのContainerView装飾用の透明ボタンはAutoLayoutで上下左右の制約を0にしているため、左端のドラッグ開始をいかにして受け取るかが実装のポイントになります。

今回の実装では、ScreenEdgePanGestureRecognizerを使用して画面の左端のドラッグ開始を検知するように実装を行いました(下記の記事を参考にSwift3.0に書き直しました)。

実装の基本部分に関しては、下記のようにメインコンテンツのContainerViewにScreenEdgePanGestureRecognizerをセットする形をとりました。
今回の実装ではコードににてScreenEdgePanGestureRecognizerをセットしています。

ViewController.swift
class ViewController: UIViewController, UIGestureRecognizerDelegate {

    ・・・(省略)・・・

    //メインコンテンツ用のコンテナビュー
    @IBOutlet weak var mainContentsContainer: UIView!

    ・・・(省略)・・・

    //左隅部分のGestureRecognizer(コンテンツが閉じた状態で仕込まれる)
    var edgeGesture: UIScreenEdgePanGestureRecognizer!

    override func viewDidLoad() {
        super.viewDidLoad()

        ・・・(省略)・・・

        //左隅部分のGestureRecognizerを作成する(デリゲートの設定と検知位置を決める)
        let edgeGesture = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(ViewController.edgeTapGesture(sender:)))
        edgeGesture.delegate = self
        edgeGesture.edges = .left

        //初期状態では左隅部分のGestureRecognizerを有効にしておく
        mainContentsContainer.addGestureRecognizer(edgeGesture)
    }

    //※サイドメニューが閉じた状態:左隅のドラッグを行ってコンテンツを開く際の処理
    func edgeTapGesture(sender: UIScreenEdgePanGestureRecognizer) {
        ・・・(※この中で閉じた状態からのドラッグ中の処理を記載する)・・・
    }

    ・・・(省略)・・・
}

※今回は割愛しますが他のGestureRecognizerの実装も上記と似たような形で実装することができますので、色々と試して見ると面白いかと思います。

また、開くドラッグ中の状態ないしはコンテンツのContainerViewが開ききった状態での各ContainerViewに対するタッチイベントの受け取りの可否については、

  • メインコンテンツのContainerView (mainContentsContainer.isUserIneractionEnabled = false)
  • サイドメニューのContainerView (sideMenuContainer.isUserIneractionEnabled = true)

という形で設定しておきます。

(2)コンテンツが開いていてドラッグでコンテンツを閉じる場合:

この場合では、すでにサイドメニューのContainerViewが表示された状態になっており、かつメインコンテンツのContainerViewと透明ボタンが重なっている部分については共にisUserIneractionEnabled = falseが設定されているためにこの2つのViewを通過して、おおもとのView(self.view)のタッチイベントを受け取るという状態になります。

この場合のポイントとしては、下記のようにおおもとのself.viewのタッチイベントを受け取った位置を元にメインコンテンツのContainerViewと透明ボタンをドラッグで動かすという処理を実装する形になります。

ViewController.swift
class ViewController: UIViewController, UIGestureRecognizerDelegate {

    ・・・(省略)・・・

    //※サイドメニューが開いた状態:タッチイベントの開始時の処理
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)

        ・・・(※おおもとのビューの位置取得などを行う)・・・
    }

    //※サイドメニューが開いた状態:タッチイベントの実行中の処理
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesMoved(touches, with: event)

        ・・・(※ドラッグでの移動量を取得してメインコンテンツと透明ボタンを動かす)・・・
    }

    //※サイドメニューが開いた状態:タッチイベントの終了時の処理
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)

        ・・・(※指が離れてドラッグが終わった際の処理を記述)・・・
    }

    ・・・(省略)・・・
}

また、閉じるドラッグ中の状態での各ContainerViewに対するタッチイベントの受け取りの可否については、

  • メインコンテンツのContainerView (mainContentsContainer.isUserIneractionEnabled = false)
  • サイドメニューのContainerView (sideMenuContainer.isUserIneractionEnabled = true)

という形で設定するのですが、コンテンツが閉じてしまった場合は、タッチイベントの受け取りの可否状態を初期状態の際と同じ設定にします。

★3-2. コンテンツの開閉制御の実装イメージ図について

上記の説明で大まかな処理の概要やタッチイベントの受け取りに関するハンドリングの設定に関しては設計できましたが、指を離した位置に応じた開閉処理に関する部分も加えていきたいと思います。

加えて上記の説明の補足として自分のノートのキャプチャも一緒に添付しておきます。タッチイベントやUIの重なりを言葉で説明するのがなかなか難しい部分ではありますが、少しでも実装イメージを膨らませる上での参考になれば幸いです^^

また、今回の処理ではコンテンツを開くまたは閉じる動作をX軸方向にドラッグさせる処理を行うような実装になっています(0以上〜240以下)。

ドラッグ処理が終了した際の位置に応じての考慮も行うように実装を行っています(基準となるのはX座標が160の位置)。

(1)コンテンツが閉じていてドラッグでコンテンツを開く場合:

capture-3-1.jpg

この場合のドラッグが終了した際の場合分けは下記のようになります。

  1. 離した位置のX座標が0〜160未満 → コンテンツを閉じた状態にする
  2. 離した位置のX座標が160〜240以下 → コンテンツを開いた状態にする

(2)コンテンツが開いていてドラッグでコンテンツを閉じる場合:

capture-3-2.jpg

この場合のドラッグが終了した際の場合分けは下記のようになります。

  1. 開いた状態でコンテンツ部分をタップする(X座標が240) → コンテンツを閉じた状態にする
  2. 離した位置のX座標が0〜160未満 → コンテンツを閉じた状態にする
  3. 離した位置のX座標が160〜240未満 → コンテンツを開いた状態にする

(3)ドラッグ終了時の位置に応じてコンテンツの開閉を決める処理について:

上記で挙げた場合分けに応じた処理を実現するために、ドラッグが終了したタイミングでのX座標の位置に応じて下記のメソッドを実行するようにします。このメソッドでは第1引数に.opened(開く) または .closed(閉じる)を設定します。

ViewController.swift
//コンテナの開閉状態を制御する
func changeContainerSetting(status: ContainerSetting) {

    //メニューが閉じている状態で押された → コンテンツを開く
    if status == .opened {

        statusSetting = .opened
        UIView.animate(withDuration: 0.16, delay: 0, options: UIViewAnimationOptions.curveEaseOut, animations: {

            //メインコンテンツを移動させてサイドメニューを表示させる
            self.mainContentsContainer.frame = CGRect(
                x: 240,
                y: self.mainContentsContainer.frame.origin.y,
                width: self.mainContentsContainer.frame.width,
                height: self.mainContentsContainer.frame.height
            )
            self.draggableButton.frame = CGRect(
                x: 240,
                y: self.draggableButton.frame.origin.y,
                width: self.draggableButton.frame.width,
                height: self.draggableButton.frame.height
            )

            //アルファを設定する
            self.draggableButton.alpha = 0.36
            self.sideMenuContainer.alpha = 1

            }, completion: { finished in

                //サイドメニューはタッチイベントを有効にする
                self.sideMenuContainer.isUserInteractionEnabled = true

                //メインコンテンツはタッチイベントを無効にする
                self.mainContentsContainer.isUserInteractionEnabled = false
            }
        )

    //メニューが開いている状態で押された → コンテンツを閉じる
    } else {

        statusSetting = .closed
        UIView.animate(withDuration: 0.16, delay: 0, options: UIViewAnimationOptions.curveEaseOut, animations: {

            //メインコンテンツを移動させてサイドメニューを閉じる
            self.mainContentsContainer.frame = CGRect(
                x: 0,
                y: self.mainContentsContainer.frame.origin.y,
                width: self.mainContentsContainer.frame.width,
                height: self.mainContentsContainer.frame.height
            )
            self.draggableButton.frame = CGRect(
                x: 0,
                y: self.draggableButton.frame.origin.y,
                width: self.draggableButton.frame.width,
                height: self.draggableButton.frame.height
            )

            //アルファを設定する
            self.draggableButton.alpha = 0
            self.sideMenuContainer.alpha = 0

            }, completion: { finished in

                //サイドメニューはタッチイベントを無効にする
                self.sideMenuContainer.isUserInteractionEnabled = false

                //メインコンテンツはタッチイベントを有効にする
                self.mainContentsContainer.isUserInteractionEnabled = true
            }
        )
    }
}

コンテンツを開く処理・閉じる処理内において、ドラッグ終了時のX座標の位置に応じてステータスを変更する処理を行う実装をしていきます。

★3-3. サイドメニューを閉じた状態からコンテンツを開く処理のポイント解説

閉じた状態からコンテンツを開く処理に関しては、コンテンツのContainerViewに追加したUIScreenEdgePanGestureRecognizerが呼ばれた際に実行される、func edgeTapGesture(sender: UIScreenEdgePanGestureRecognizer)メソッド内に下記のような実装を行っていきます。

ViewController.swift
//※サイドメニューが閉じた状態:左隅のドラッグを行ってコンテンツを開く際の処理
func edgeTapGesture(sender: UIScreenEdgePanGestureRecognizer) {

    //サイドメニューのタッチイベントを有効にする
    sideMenuContainer.isUserInteractionEnabled = true

    //メインコンテンツのタッチイベントを無効にする
    mainContentsContainer.isUserInteractionEnabled = false

    //移動量を取得する
    let move: CGPoint = sender.translation(in: mainContentsContainer)

    //メインコンテンツと透明ボタンのx座標に移動量を加算する
    draggableButton.frame.origin.x += move.x
    mainContentsContainer.frame.origin.x += move.x

    //Debug.
    //print("サイドコンテンツが閉じた状態でのドラッグの加算量:\(move.x)")

    //サイドメニューとボタンのアルファ値を変更する
    sideMenuContainer.alpha = mainContentsContainer.frame.origin.x / 240
    draggableButton.alpha = mainContentsContainer.frame.origin.x / 240 * 0.36

    //メインコンテンツのx座標が0〜240の間に収まるように補正
    if mainContentsContainer.frame.origin.x > 240 {
        mainContentsContainer.frame.origin.x = 240
        draggableButton.frame.origin.x = 240
    } else if mainContentsContainer.frame.origin.x < 0 {
        mainContentsContainer.frame.origin.x = 0
        draggableButton.frame.origin.x = 0
    }

    //ドラッグ終了時の処理
    /**
     * 境界値(x座標:160)のところで開閉状態を決める
     * ボタンエリアが開いた時の位置から変わらない時(x座標:240)または境界値より前ではコンテンツを閉じる
     */
    if sender.state == UIGestureRecognizerState.ended {
        if mainContentsContainer.frame.origin.x < 160 {
            changeContainerSetting(status: .closed)
        } else {
            changeContainerSetting(status: .opened)
        }
    }

    //移動量をリセットする
    sender.setTranslation(CGPoint.zero, in: self.view)
}

ポイントとなるのは、let move: CGPoint = sender.translation(in: mainContentsContainer)の部分でドラッグ中の移動量が取得できるので、その値をメインコンテンツのContainerViewと透明ボタンのX座標の値に加算していくことでドラッグ処理を実現しています。

★3-4. サイドメニューを開いた状態からコンテンツを閉じる処理のポイント解説

開いた状態からコンテンツを閉じる処理に関しては、おおもとのViewのタッチイベントを受け取った際に実行される、

  • タッチイベント開始時:override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
  • タッチイベント動作中:override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)
  • タッチイベント終了時:override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)

の3つのメソッド内に下記のような実装を行っていきます。

ViewController.swift
//このViewControllerのタッチイベント開始時のx座標(コンテンツが開いた状態で仕込まれる)
var touchBeganPositionX: CGFloat!

・・・(省略)・・・

//※サイドメニューが開いた状態:タッチイベントの開始時の処理
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesBegan(touches, with: event)

    //サイドメニューが開いた際にタッチイベント開始位置のx座標を取得してメンバ変数に格納する
    let touchEvent = touches.first!

    //タッチイベント開始時のself.viewのx座標を取得する
    let beginPosition = touchEvent.previousLocation(in: self.view)
    touchBeganPositionX = beginPosition.x

    //Debug.
    //print("サイドコンテンツが開いた状態でのドラッグ開始時のx座標:\(touchBeganPositionX)")
}

//※サイドメニューが開いた状態:タッチイベントの実行中の処理
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesMoved(touches, with: event)

    //タッチイベント開始位置のx座標がサイドナビゲーション幅より大きい場合
    // → メインコンテンツと透明ボタンをドラッグで動かすことができるようにする
    if statusSetting == .opened && touchBeganPositionX >= 240 {

        let touchEvent = touches.first!

        //ドラッグ前の座標
        let preDx = touchEvent.previousLocation(in: self.view).x

        //ドラッグ後の座標
        let newDx = touchEvent.location(in: self.view).x

        //ドラッグしたx座標の移動距離
        let dx = newDx - preDx

        //画像のフレーム
        var viewFrame: CGRect = draggableButton.frame

        //移動分を反映させる
        viewFrame.origin.x += dx
        mainContentsContainer.frame = viewFrame
        draggableButton.frame = viewFrame

        //Debug.
        //print("サイドコンテンツが開いた状態でのドラッグ中のx座標:\(viewFrame.origin.x)")

        //メインコンテンツのx座標が0〜240の間に収まるように補正
        if mainContentsContainer.frame.origin.x > 240 {
            mainContentsContainer.frame.origin.x = 240
            draggableButton.frame.origin.x = 240
        } else if mainContentsContainer.frame.origin.x < 0 {
            mainContentsContainer.frame.origin.x = 0
            draggableButton.frame.origin.x = 0
        }

        //サイドメニューとボタンのアルファ値を変更する
        sideMenuContainer.alpha = mainContentsContainer.frame.origin.x / 240
        draggableButton.alpha = mainContentsContainer.frame.origin.x / 240 * 0.36
    }

}

//※サイドメニューが開いた状態:タッチイベントの終了時の処理
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesEnded(touches, with: event)

    //タッチイベント終了時はメインコンテンツと透明ボタンの位置で開くか閉じるかを決める
    /**
     * 境界値(x座標:160)のところで開閉状態を決める
     * ボタンエリアが開いた時の位置から変わらない時(x座標:240)または境界値より前ではコンテンツを閉じる
     */
    if touchBeganPositionX >= 240 && (mainContentsContainer.frame.origin.x == 240 || mainContentsContainer.frame.origin.x < 160) {
        changeContainerSetting(status: .closed)
    } else if touchBeganPositionX >= 240 && mainContentsContainer.frame.origin.x >= 160 {
        changeContainerSetting(status: .opened)
    }

}

こちらの処理に関しても、処理の記載方法はScreenEdgePanGestureRecognizerを利用した処理とは異なるものの、コンテンツを開く際の処理と同じ考え方になります。

(ドラッグ後のX座標 - ドラッグ前のX座標)でドラッグ中の移動量が取得できるので、その値をメインコンテンツのContainerViewと透明ボタンのX座標の値に加算していくことでドラッグ処理を実現しています。

★3-5. 動きをトレースする際に参考にしたライブラリ

この部分ではFacebookで使用されている右側ナビゲーションの動きに近しいものを実現するために、下記のライブラリの動きも併せて参考にしました。

上記のライブラリに限らずサイドメニューに関するライブラリは数多くあり、また様々なアプリの中で導入されているポピュラーなものになるかと思います。

しかしながら、このような動きを自前で実装していく場合には、

  • ドラッグ時の移動距離の取り方や指を離した位置での動きの考慮
  • 重なっているContainerViewに関するタッチイベントの有効・無効の切り替え
  • アニメーションを発動させるタイミング

等の考慮しなければいけない要素がたくさん詰まっているので、処理の切り分けやデバッグには注意が必要になると感じました。

今回の実装については、

  • コンテンツを開く際 → ScreenEdgePanGestureRecognizerを利用した処理
  • コンテンツを閉じる際 → self.view(おおもとのView)のタッチイベントを利用した処理

と処理の仕方を分けている形をとりましたが、この辺りの実装に関してはまだまだシンプルにできそうな余地があるかもと思っていますので今後も追いかけていきたいですね。

※「もっと綺麗な実装方法があるよ!」という実装アイデアをお持ちの方がいらっしゃいましたらコメント欄への記載やPullRequestをお送り頂けますと嬉しく思います。

※サイドメニューのContainerViewに配置するUI部品に関しても、ユーザーのタッチイベントを受け取るもの(UIButtonやUITableView・UICollectionView等)ではない場合には、isUserInteractionEnabledプロパティをtrueにしておく(またはStoryboardの右側の「User Interaction Enabled」にチェックをつけておく)と、「コンテンツのContainerViewを動かした状態で別の指でサイドメニューのContainerViewをいじる」ような意地悪なドラッグをした際にメインコンテンツの移動が止まって固まる状態を防止できるので、この部分は少し注意が必要かもしれません。

userinteraction.png

4. カテゴリー選択ボタン部分のスクロール&中のボタンタップ時の位置補正の実装ポイント

キュレーションメディアアプリ等でもよく見かける、記事のカテゴリー選択部分のナビゲーション部分の実装を解説していきます。大まかな流れとしては、

  1. UIScrollView内にボタンと現在位置を表す色付きの線(UILabel)をコードで配置
  2. UIScrollView内のボタンを押下すると、押されたボタンと色付きの線が中央に来る(最初のボタン場合は左端に、最後のボタンの場合は右端に来る)ように実装する

という感じになります。

★4-1. UIScrollViewの設定とviewDidLayoutSubViews内の処理の注意点

外枠となるUIScrollViewをStoryboardで配置してAutoLayoutで制約を決めます(今回は上:0, 左右:0, 高さ:40としました)。次にボタンと動く色付きの線をviewDidLayoutSubViews内で配置をしていくのですが、ここで注意すべき点があります。

今回はコンテンツのContainerViewに表示しているTableViewコンテンツを下方向にスクロールをした際にナビゲーションバーとこのUIScrollViewのパーツを隠すという処理をAutoLayoutの制約を変更するアニメーションで実装しています。

しかし、そのままviewDidLayoutSubViews内にそのまま配置処理を書いてしまうと、ナビゲーションバーを隠すまたは再表示するアニメーションが実行される度に、ボタンと動く色付きの線が配置されてしまうので、この部分の処理に関しては最初の一度だけ呼ばれるように制御するようにしましょう。

★4-2. この動きを実現するための実装イメージ図について

実装のイメージ図は下記のようになります。今回は9つのボタンを「3つ(コンテンツ幅/3)を1セットでScrollView内に見せる」ように配置を行っています。

capture-4-1.jpg

★4-3. 実際のコードと実装するに当たってのポイント解説

上記で解説を行った注意点やイメージ図での設計を踏まえて実装を行ったコードが下記のものになります。

viewDidLayoutSubViews内で一度だけボタンと動く色付きの線を配置する処理に関しては、Swift3.0では、一度だけ実行する処理の際に使われるdispatch_once()が廃止されてしまったので、代わりにfileprivate var layoutOnceFlag: Bool = falseという変数で行っています。

スクロールビュー内及び動く色付きの線に関するアニメーションに関する処理は、

  • moveFormNowButtonContentsScrollView(page: Int)でScrollViewのcontentOffsetの値を変更して位置調整を行う
  • moveToCurrentButtonLabelButtonTapped(page: Int)で動く色付きの線の位置調整を行う

というような形になります。

MainContentsController.swift
//スクロールビュー内のボタンを一度だけ生成するフラグ
fileprivate var layoutOnceFlag: Bool = false

//スクロール内の動くラベル
fileprivate let movingLabel = UILabel()

・・・(省略)・・・

//レイアウト処理が完了した際の処理
override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    //動的に配置する見た目要素は一度だけ実行する
    if layoutOnceFlag == false {

        //コンテンツ用のScrollViewを初期化
        initScrollViewDefinition()

        //スクロールビュー内のサイズを決定する
        multiButtonScrollView.contentSize = CGSize(
            width: CGFloat(Int(multiButtonScrollView.frame.width / 3) * ScrollButtonList.buttonList.count),
            height: multiButtonScrollView.frame.height
        )

        //メインのスクロールビューの中にコンテンツ表示用のコンテナを一列に並べて配置する
        for i in 0...(ScrollButtonList.buttonList.count - 1) {

            //メニュー用のスクロールビューにボタンを配置
            let buttonElement: UIButton! = UIButton()
            multiButtonScrollView.addSubview(buttonElement)

            buttonElement.frame = CGRect(
                x: CGFloat(Int(multiButtonScrollView.frame.width) / 3 * i),
                y: 0,
                width: multiButtonScrollView.frame.width / 3,
                height: multiButtonScrollView.frame.height
            )
            buttonElement.backgroundColor = UIColor.clear
            buttonElement.setTitle(ScrollButtonList.buttonList[i], for: UIControlState())
            buttonElement.setTitleColor(UIColor.gray, for: UIControlState())
            buttonElement.titleLabel!.font = UIFont(name: "Georgia-Bold", size: 11)!
            buttonElement.tag = i
            buttonElement.addTarget(self, action: #selector(MainContentsController.scrollButtonTapped(button:)), for: .touchUpInside)

        }

        //動くラベルの配置
        multiButtonScrollView.addSubview(movingLabel)
        multiButtonScrollView.bringSubview(toFront: movingLabel)
        movingLabel.frame = CGRect(
            x: 0,
            y: SlideMenuSetting.movingLabelY,
            width: Int(self.view.frame.width / 3),
            height: SlideMenuSetting.movingLabelH
        )
        movingLabel.backgroundColor = UIColor.orange

        //一度だけ実行するフラグを有効化
        layoutOnceFlag = true
    }

}

//コンテンツ用のUIScrollViewの初期化を行う
fileprivate func initScrollViewDefinition() {

    //(重要)MainContentsControllerの「Adjust Scroll View Insets」のチェックを外しておく
    //スクロールビュー内の各プロパティ値を設定する
    //※注意: 今回は配置したボタン押下時の位置補正をするだけなので、このUIScrollViewに対してのUIScrollViewDelegateの処理はない
    multiButtonScrollView.isPagingEnabled = false
    multiButtonScrollView.isScrollEnabled = true
    multiButtonScrollView.isDirectionalLockEnabled = false
    multiButtonScrollView.showsHorizontalScrollIndicator = false
    multiButtonScrollView.showsVerticalScrollIndicator = false
    multiButtonScrollView.bounces = false
    multiButtonScrollView.scrollsToTop = false
}

・・・(省略)・・・

//ボタンをタップした際に行われる処理
func scrollButtonTapped(button: UIButton) {

    //押されたボタンのタグを取得
    let page: Int = button.tag

    //コンテンツを押されたボタンに応じて移動する
    moveToCurrentButtonLabelButtonTapped(page: page)
    moveFormNowButtonContentsScrollView(page: page)
}

//ボタンタップ時に動くラベルをスライドさせる
fileprivate func moveToCurrentButtonLabelButtonTapped(page: Int) {

    UIView.animate(withDuration: 0.26, delay: 0, options: [], animations: {

        self.movingLabel.frame = CGRect(
            x: Int(self.view.frame.width) / 3 * page,
            y: SlideMenuSetting.movingLabelY,
            width: Int(self.view.frame.width) / 3,
            height: SlideMenuSetting.movingLabelH
        )
    }, completion: nil)
}

//ボタンのスクロールビューをスライドさせる
fileprivate func moveFormNowButtonContentsScrollView(page: Int) {

    //Case1:ボタンを内包しているスクロールビューの位置変更をする
    if page > 0 && page < (ScrollButtonList.buttonList.count - 1) {

        scrollButtonOffsetX = Int(multiButtonScrollView.frame.width) / 3 * (page - 1)

    //Case2:一番最初のpage番号のときの移動量
    } else if page == 0 {

        scrollButtonOffsetX = 0

    //Case3:一番最後のpage番号のときの移動量
    } else if page == (ScrollButtonList.buttonList.count - 1) {

        scrollButtonOffsetX = Int(multiButtonScrollView.frame.width) * (ScrollButtonList.buttonList.count / 3 - 1)
    }

    UIView.animate(withDuration: 0.26, delay: 0, options: [], animations: {
        self.multiButtonScrollView.contentOffset = CGPoint(
            x: self.scrollButtonOffsetX,
            y: 0
        )
    }, completion: nil)
}

この部分に関しては、一見するとコード量が多く感じられるかもしれませんが、処理を追っていくと、していることは、「ボタンがタップされた際にボタンのtagプロパティの値を元に位置の補正に関する処理」という結構シンプルなものになります。

※もうちょっとエレガントに書けるように精進します><

★4-4. 動きをトレースする際に参考にしたライブラリ

今回はボタン部分の実装に関してだけ切り出しての実装を行いましたが、この動きをするライブラリはほとんどの場合はコンテンツの切り替えと一緒になっている場合が多いので、メディア系のアプリを作る際には参考にしたりする機会も多いのではないかと思います。

また、アニメーションも加わる場合が多いので、実装やデザインの次第では「目を引くワンポイント」となる部分にも活用してみても面白いかもしれませんね。

5. 下向きのスクロールを検知してナビゲーションを隠す動きを作る実装ポイント

下向きのスクロールを行った際に、コンテンツのエリアの縦方向の幅を広げてより多くのコンテンツを見せるための配慮も込めて、様々なアプリの中でもよく見かけるポピュラーなアニメーション処理だと思います。細かな部分ではありますが、この動きがあるだけでもユーザビリティが良いなと感じます。

★5-1. UIScrollViewDelegeteのscrollViewWillBeganDraggingとscrollViewDidScrollを使って方向と移動量を算出する

今回のアニメーションを実装するポイントとしましては、UIScrollViewDelegeteの次の2つのメソッドで取得できる、scrollView.contentOffsetの値を元にスクロールの変化量を算出して方向を判定するような形になります。

  • スクロール開始位置(Y座標):scrollViewWillBeganDraggingメソッドで取得するscrollView.contentOffsetの値
  • スクロール終了位置(Y座標):sscrollViewDidScrollメソッドで取得するscrollView.contentOffsetの値

として、この2つの変化量を元に、スクロールが動いた方向とスクロールの移動量を元にステータスバーを隠すないしは再表示に関する処理を実装していく流れになります。

★5-2. この動きを実現するための実装イメージ図について

今回のUIでは、ステータスバーの下にナビゲーションバーがあり、更にその下に前述のスクロールビューと連動したボタンがある作りになっています。今回は方向の検知に併せてなおかつ、ある程度の変化量に到達したタイミングで、ナビゲーションバーと前述のスクロールビューと連動したボタンが段違いになる感じの動きで、隠れるないしは再表示されるような実装を行います。

capture-5-1.jpg

あとは細かな点になりますが、下記のコードでステータスバーの部分にNavigationBarと同じ色を入れておくと良いかもしれませんね。

MainContentsController.swift
//ステータスバーの部分に背景色をつける(viewDidLoad内に記述)
let statusBar = UIView(frame:CGRect(x: 0, y: 0, width: UIScreen.main.bounds.size.width, height: 20))
statusBar.backgroundColor = UIColor.white
self.view.addSubview(statusBar)
★5-3. 実際のコードと実装するに当たってのポイント解説

上記で解説を行ったスクロールの方向や変化量の取得の仕方のポイントやイメージ図での設計を踏まえて実装を行ったコードが下記のものになります。

MainContentsController.swift
//テーブルビューのスクロールの開始位置を格納する変数
fileprivate var scrollBeginingPoint: CGPoint!

・・・(省略)・・・

//スクロール開始位置を取得
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
    scrollBeginingPoint = scrollView.contentOffset
}

//スクロールが検知された時に実行される処理
func scrollViewDidScroll(_ scrollView: UIScrollView) {

    //パララックスをするテーブルビューの場合
    if scrollView == parallaxTableView {

        //画面に表示されているセルの画像のオフセット値を変更する
        for indexPath in parallaxTableView.indexPathsForVisibleRows! {
            setCellImageOffset(parallaxTableView.cellForRow(at: indexPath) as! PictureCell, indexPath: indexPath)
        }

        //スクロール終了時のy座標を取得する
        let currentPoint = scrollView.contentOffset

        //下方向のスクロールを行った場合の処理
        if scrollBeginingPoint.y < currentPoint.y {

            //自作メニューを隠して、変化量が40以上であればナビゲーションバーも一緒に隠す
            topMenuConstraint.constant = -40
            UIView.animate(withDuration: 0.26, delay: 0, options: UIViewAnimationOptions.curveEaseOut, animations: {

                //変更したAutoLayoutのConstant値を適用する
                self.view.layoutIfNeeded()
                }, completion: { finished in
            })

            if currentPoint.y - self.scrollBeginingPoint.y > 40 {
                hideNavigationFromChildView(direction: .lower)
            }

        //上方向のスクロールを行った場合の処理
        } else {

            //ナビゲーションバーを表示して、変化量が40以上であれば自作メニューも一緒に表示
            hideNavigationFromChildView(direction: .upper)

            if scrollBeginingPoint.y - currentPoint.y > 40 {

                topMenuConstraint.constant = 0
                UIView.animate(withDuration: 0.26, delay: 0, options: UIViewAnimationOptions.curveEaseOut, animations: {

                    //変更したAutoLayoutのConstant値を適用する
                    self.view.layoutIfNeeded()
                    }, completion: { finished in
                })
            }

        }
    }

}

//NavigationBarを隠すメソッド
func hideNavigationFromChildView(direction: ContentsScrollDirection) {
    if direction == .lower {
        navigationController?.setNavigationBarHidden(true, animated: true)
    } else {
        navigationController?.setNavigationBarHidden(false, animated: true)
    }
}

このコードの処理のポイントは、

  • 「ドラッグ開始時のY座標 < ドラッグ終了時のY座標」ならば、スクロールビューと連動したボタンを隠し、変化量が40を超えた場合にはナビゲーションバーを隠す
  • 「ドラッグ開始時のY座標 > ドラッグ終了時のY座標」ならば、ナビゲーションバーを再表示し、変化量が40を超えた場合にはスクロールビューと連動したボタンを再表示する

という2パターンの挙動になっています。また、ナビゲーションバーを隠すため処理は、setNavigationBarHiddenメソッドを利用して行います。

またこのメソッドの引数は下記のようになります。

  • 第1引数:ナビゲーションバーを隠すか否かの判定
  • 第2引数;アニメーションの有無

※再表示させる際の動きはナビゲーションバーとスクロールビューと連動したボタンを隠した際の順番と逆になるように実装しています。

★5-4. 動きをトレースする際に参考にしたライブラリ

このアニメーションの動きに関しても、細かい部分ではありながらも数多くのライブラリが公開されているのですが、今回はこちらのライブラリの動きも併せて参考に実装をしました。

UIの作成の作業は、一見するととても華やかなイメージがありますが、実際の処理はなかなか細かい部分も多く、開発者の細かな気配りやこだわりを垣間見ることができる部分なのかもしれないなと個人的には感じています。

補足1. Parallax(視差効果)を利用した画像の表現をする際の実装の参考資料

こちらの実装に関しては以前に私が書いた記事で恐縮ではありますが、下記の記事の中でその実装方法を紹介しています。この記事内ではSwift2.2の実装になっていますが、そのままSwift3.0で書き換えたもので今回は実現してみました。

前述のリンクで紹介した記事では自前でParallax(視差効果)処理を実装している形を取っていますが、下記のようなライブラリもありますので、ライブラリ内の実装を見てみたりするのも参考になるかと思います。(下記で紹介しているライブラリはObjective-Cで実装されています)

特に画像の良さを生かしたデザインの中に小気味の良い動きを加えたい場合等に活用を検討してみても良いかもしれませんね。

補足2. カテゴリー選択部分を無限スクロールにする際の参考資料

今回紹介した「4. カテゴリー選択ボタン部分のスクロール&中のボタンタップ時の位置補正の実装ポイント」の部分に関しましては、今回は最終位置が決まっている形のスクロールビューを用いた実装にしましたが、実装の仕方によってはこの部分を無限スクロールにすることも可能です。主に横方向の無限スクロールを実現する方法としてはUIScrollViewないしはUICollectionViewを利用する形になるかと思います。

下記に参考になりそうなリンクをピックアップして掲載をしておきますので、ご参考になれば幸いです。
※Objective-Cのものや以前のSwiftバージョンの記事も含まれますが、実装の方法を考える上でも参考になりました。

UIScrollViewを使用した無限スクロールの事例:

UICollectionViewを使用した無限スクロールの事例:

あとがき

今回は以前の記事に比べると割と追加実装に近しい感じのTIPSを紹介してみました。細かい動きの一つ一つは少しのコード量でも実現可能なものであったり、アプリの土台部分となる実装にさらに追加をすることでUIの表現をよりリッチにできます。

また、実装以前の画面やUIの設計段階においても「参考にしたいライブラリの実装をあえてライブラリなしで作るアプローチ」をした知識や知見・簡単なショーケースがあるだけでも、

  • ライブラリを導入することでUI部分を組み立るか?
  • 自分でUIパーツを駆使してUI部分を組み立てるか?

の選択を行う際にも手助けとなってくれるのではないかと思っています。また機能追加やリファクタリング等の際に既存のUI実装を新しく置き換えるような作業等の発生も十分に起こり得る可能性があるので、そんな際にも対応したり、ライブラリや既存実装と同じないしは近しい実装を再現する場合に備えて、UIKitの特性や性質を深堀りした上で再現できるショーケースをたくさん用意しておくと、後々で立つかもと感じた次第です。

またライブラリを使用して実装するにせよ、自分でUIパーツを駆使して実装するにせよ、メジャーなライブラリの動きや機能に関しても下記の記事等で確認しておくと様々な活用法や実装アイデアが浮かぶと思います。

そして、今回の記事の内容が少しでも実装の際にお役に立つことができればと思いますm(_ _)m

fumiyasac@github
iOS Developer & Ruby & PHP Engineer (Sometimes UI Design) 本業は都内でプログラマをしています。まだまだ学ぶことは沢山ありますが、「整理・負担を軽く」「感謝され、期待に添えること」コンセプトに技術を磨く毎日です。最近はSwift/Objective-C/PHP/Ruby/ReactNative/KotlinとUI実装が大好物(^_^)
https://just1factory.net/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした