20200217のSwiftに関する記事は5件です。

StackView in a ScrollView

どうも、iOSアプリ開発をしておりますRyu1です。
最近ふかみんというあだ名を付けてもらって少し気に入ってます。
今回は、UIScrollViewにUIStackViewを配置して、スクロールする方法を紹介します。

何故この記事を書くか

UIScrollViewにUIStackViewを配置してスクロールする方法を紹介している記事は既にいくつかあるものの、

  • Xcode11で追加されたframe and content layout guidesと合わせて書いてある記事が少ない
  • ScrollViewのContent sizeをわかりやすく解説している記事が少なかった。

などといった理由で、本記事を書くことにしました。

環境

  • Xcode Version 11.3.1
  • Swift5

実現したいこと

UIScrollViewにUIStackViewを重ねて、UIStackViewにUIViewを5つ程配置してスクロールする。

↓こんな感じ。
scroll.gif

必要となる知識

frame layout guides

Appleのドキュメントに以下のように記載されている通り、

Use this layout guide when you want to create Auto Layout constraints that explicitly involve the frame rectangle of the scroll view itself, as opposed to its content rectangle.

と書かれているので、ScrollView自体の形を指定する制約を設定する際に、このlayout guideを用います。

content layout guides

同様に、Appleのドキュメントに以下のように記載されている通り、

Use this layout guide when you want to create Auto Layout constraints related to the content area of a scroll view.

ScrollViewの中のContent area(今回で言うとStackView)に関する制約を設定する際に、このlayout guideを用います。

frame and content layout guides

上記の

  • frame layout guides
  • content layout guides

は、あくまでlayoutを組むのを手助けするものであり、必ずしも必要なものではありません。
実装の欄で制約の組み方を見ていただければ、これらがなくとも実装可能であることが確認できると思います。
これらを使うかどうかは、以下のチェック項目で選択できます。
スクリーンショット 2020-02-18 9.49.23.png

ScrollViewのcontent size

今回のように、ScrollViewのContent ViewをAuto layoutで組む場合は、Content sizeを設定してあげなければなりません。これは、Scrollable Areaの範囲を設定するためで、Scroll Viewの制約がなかなか定まらないのはこのためです。
Content SizeがScroll Viewのframe sizeよりも大きい場合のみScrollできます。

また、Auto layoutとCodeで組む場合で、それぞれContent sizeの設定の仕方が異なります。

Auto layoutの場合

以下の2点を設定する必要があります。
- Content View(今回でいうStack View)のwidthとheightの両方
- Content View(今回でいうStack View)の4辺をScroll Viewに合わせる

Codeの場合
scrollview.contentSize = CGSize(width: 640, height: 800)

intrinsic content sizeとは

intrinsicとは、固有のという意味で、intrinsic content sizeは、Viewのコンテンツを表示するための最低のサイズを表しています。
UIViewのプロパティであるintrinsicContentSizeは読み取り専用ですが、UIViewのサブクラスで、以下のように、intrinsicContentSizeをオーバーライドすることでintrinsicContentSizeを変更することもできます。

class ExampleView: UIView {

    public override func awakeFromNib() {
        super.awakeFromNib()
    }

    override var intrinsicContentSize: CGSize {
        CGSize(width: 170, height: 230)
    }

また、XibやStoryBoardでも以下のように、Intrinsic SizeをDefaultからPlaceholderにして設定することができます。またこれは、編集時のみ適応されるものであり、ランタイムには無視されるため、中身の大きさによってそのViewのサイズが変更される場合に、使い勝手がいいです。
スクリーンショット 2020-02-17 23.38.07.png

実装

0. ファイル生成

今回は画面繊維や細かいViewを使う必要はないので、
New file...Cocoa Touch ClassUIViewControllerを継承した.swiftファイルとAlso create XIB fileを選択して、xibファイルを生成。
つまり、今回主に扱うファイルは2つです。

1. xibでUIを組む。

1.1 UIScrollView

まずはScrollViewを配置します。
AppleのAuto Layout Guideにも以下のように記載されている通り、特別なことは何もせずに、通常通り制約を付けます。

Any constraints between the scroll view and objects outside the scroll view attach to the scroll view’s frame, just as with any other view.

以下のように制約を組みます。
スクリーンショット 2020-02-17 23.28.49.png
スクリーンショット 2020-02-17 23.29.23.png

ここで、ScorllViewの中にContent Layout GuideFrame Layout Guideというものに目が行くかと思いますが、一旦無視します。
また、この時点で制約が足りないと言われていますが、それも無視します。(これは、

1.2 UIStackView

まずは、UIStackViewをUIScrollViewの上に配置します。
Distributionなどの設定は以下の通りです。
スクリーンショット 2020-02-17 23.39.58.png

1.2.1 Frame Layout Guide

その後、上の説明で記載した通り、frame layout guidesを用いてScrollView自体の形に関する制約を付けます。
以下のように、StackViewFrame Layout Guideを同時に選択した状態で、Add New Constraintsを押し、Equal Heightsを設定します。
スクリーンショット 2020-02-17 23.58.38.png

1.2.2 Content Layout Guide

上の説明で記載した通り、content layout guidesを用いてStackViewに関する制約を付けます。
content.gif
上の操作によって、下のような制約を付けます。(多少値を修正する必要あり)
スクリーンショット 2020-02-18 0.58.55.png

1.2.3 Scroll Viewの横幅の設定

この状態では、以下のような赤文字が出てしまいます。
スクリーンショット 2020-02-17 23.35.39.png
Scroll Viewの横幅(つまり、その子であるStack Viewの横幅)に関する制約が不足しているそうです。ここで、StackViewの横幅を設定する必要があるのですが、注意が必要で、Scroll Viewの横幅を単純にSuper Viewとイコール関係で追加してしまっては、Priorityが、Stack Viewに配置するUIViewの大きさの設定のPriorityよりも下がらないままStackViewの横幅が設定されてしまいます。
その結果、

  • StackViewの横幅を縛ってしまうので、Scrollしない
  • UIViewの大きさがおかしくなる という事態が発生してしまいます。

よって、ここで設定すべきは、Stack Viewのintrinsic content sizeです。

1.2.4 intrinsic content sizeの設定

intrinsic content sizeの説明を見てみると、

Setting a design time intrinsic content size only affects a view while editing in Interface Builder. The view will not have this intrinsic content size at runtime.

と書かれてており、ここで設定したサイズは、IBの編集中にのみ適応されるもので、ランタイムはここでなされた設定は無視されます。
スクリーンショット 2020-02-17 23.38.07.png
横幅のみでいいので、HeightはNoneにチェックを入れます。Widthの値は何でも良いので、0を入れておきます。
これによって、StackViewの横幅を縛ることなく、StackViewに配置されたUIViewによってStack Viewの横幅を決めれることで、適切にスクロールさせることができます。

また、intrinsic content sizeを使わずに、Stack Viewのwidth >= 0というように設定し、priorityを下げる、という方法でもうまくいきますが、Stack ViewにStackするコンテンツによって上書きされてしまうので、無駄な制約をつけることになってしまうかつ、ランタイム中に使わない制約を付けてしまうという意味でもあまりいい手法とは言えません。よって、今回はintrinsic content sizeを使って、暫定的な幅を設定してあげるのが良さそうです。

3. IBとコードを繋げる

ここに関する説明は特にいらないと思うので、割愛します。

import UIKit

class ScrollStackViewController: UIViewController {

    @IBOutlet weak var stackView: UIStackView!

    override func viewDidLoad() {
        super.viewDidLoad()

        prepareForStackView()
    }

    // Stack Viewと同じのheightのUIViewを5つStack Viewに配置する
    private func prepareForStackView() {
        let stackedViewSize = stackView.bounds.height

        for _ in 1..<5 {
            let stackedView = UIView()
            stackedView.backgroundColor = .black
            stackedView.widthAnchor.constraint(equalToConstant: stackedViewSize).isActive = true
            stackedView.heightAnchor.constraint(equalToConstant: stackedViewSize).isActive = true
            stackView.addArrangedSubview(stackedView)
        }
    }
}

完成

以上の手順を踏むと、冒頭で掲載した通りの、Stack ViewをScrollするというViewを実装できます。
scroll.gif

参考文献

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

凝集度の観点から関数分割を考える(iOS)

はじめに

2020/02/16(日)に「Object-Oriented Conference」というカンファレンスが行われました。
そこで聴講した「オブジェクト指向のその前に — 凝集度と結合度」というセッションに感銘を受け、自分でも凝集度についてまとめたいと思い、本記事を書きました。

私はiOSアプリエンジニアなのでiOS + Swiftの目線となっていますが、「凝集度」という概念はプログラミング言語に関係なく適用できます。

「凝集度」とは?

英語では「Cohesion」といいます。
日本語でも複数の呼び方があり、IPAでは「強度(Strength)」と呼んでいます。

あるコードがどれだけそのクラス(関数)の責任分担に集中しているかを示す度合いです。
凝集度が低いほど関連のない処理が混ざっていて、高いほど機能が絞られていて望ましいです。

凝集度が高いと何がいいか?

可読性が上がる

1つのことしか行っていない関数は、処理の目的が明確なので可読性が高いです。
関数は「一連の処理に名前を付けること」とも考えられるので、関数名が適切だとさらに読みやすいです。

再利用性が上がる

機能ごとに処理が分割されているので、再利用性が高いです。
凝集度が低い関数は様々な処理が混ざっているので、他で使い回しづらいです。

主な凝集の種類

主な凝集の種類を、凝集度の低順に紹介します。

偶発的凝集(最悪、凝集度が低い)

英語では「Coincidental Cohesion」といいます。
最も凝集度が低い凝集であり、関連のない複数の処理が1つの関数内に実装されている状態です。

func doCoincidentalCohesion() {
    // インジケータのアニメーションとラベルの生成処理は関連がない
    activityIndicatorView.startAnimating()

    nameLabel = UILabel()
    nameLabel.backgroundColor = .blue
    view.addSubView(nameLabel)
}

論理的凝集

英語では「Logical Cohesion」といいます。
論理的に似ている処理が1つの関数内に実装されている状態であり、引数などをフラグにして処理を分岐させているのが特徴です。

func createNameLabel(isBlue: Bool) {
    // ラベルの色を青にするかどうかを引数のフラグで判断している
    nameLabel = UILabel()
    if isBlue {
        nameLabel.backgroundColor = .blue
    } else {
        nameLabel.backgroundColor = .white
    }
    view.addSubView(nameLabel)
}

時間的凝集

英語では「Temporal Cohesion」といいます。
特定の時間に実行される処理が1つの関数内に実装されている状態です。

基本的には避けるべきですが、ビューのライフサイクルやメイン関数では避けられません。
iOSアプリ開発の場合、 viewDidLoad() などが当てはまります。

override func viewDidLoad() {
    super.viewDidLoad()

    // ラベルの生成中にインジケータを表示している
    activityIndicatorView.startAnimating()

    nameLabel = UILabel()
    nameLabel.backgroundColor = .blue
    view.addSubView(nameLabel)

    activityIndicatorView.stopAnimating()
}

機能的凝集(最良、凝集度が高い)

英語では「Functional Cohesion」といいます。
単一の処理が1つの関数内に実装されている状態です。
理想的であり、どの関数も基本的には機能的凝集を目指すべきです。

func createNameLabel() {
    // 「名前ラベルの生成」という機能のみ持っている
    nameLabel = UILabel()
    nameLabel.backgroundColor = .blue
    view.addSubView(nameLabel)
}

凝集度を高くするには

基本的には機能的凝集を目指すことで凝集度が高くなります。

偶発的凝集

それぞれが機能的凝集となるように関数を分割します。

ここでは「インジケータのアニメーション開始」と「名前ラベルの生成」という単一の機能を持った関数に分割しました。

after
// 機能的凝集の関数
func startIndicator() {
    activityIndicatorView.startAnimating()
}

// 機能的凝集の関数
func createNameLabel() {
    nameLabel = UILabel()
    nameLabel.backgroundColor = .blue
    view.addSubView(nameLabel)
}

論理的凝集

論理的凝集された関数の共通部分を関数に抜き出し、それを呼び出す複数の関数を実装します。

ここでは「名前ラベルの生成」を共通部分の関数として抜き出し、「青い名前ラベルの生成」と「白い名前ラベルの生成」の関数から呼び出しています。
分岐を減らせるので、テストコードが書きやすくなります。

after
func createBlueNameLabel() {
    createNameLabel()
    nameLabel.backgroundColor = .blue
}

func createWhiteNameLabel() {
    createNameLabel()
    nameLabel.backgroundColor = .white
}

private func createNameLabel() {
    nameLabel = UILabel()
    view.addSubView(nameLabel)
}

時間的凝集

先述した通り、ビューのライフサイクルなど時間的凝集を避けられないことがあります。
その場合、処理をベタ書きするのでなく、機能的凝集された関数を呼び出すのみにするのが望ましいです。

after
override func viewDidLoad() {
    super.viewDidLoad()

    startIndicator()
    createNameLabel()
    stopIndicator()
}

// 機能的凝集の関数
private func startIndicator() {
    activityIndicatorView.startAnimating()
}

// 機能的凝集の関数
private func stopIndicator() {
    activityIndicatorView.stopAnimating()
}

// 機能的凝集の関数
private func createNameLabel() {
    nameLabel = UILabel()
    nameLabel.backgroundColor = .blue
    view.addSubView(nameLabel)
}

おまけ:参考文献

冒頭で紹介したセッションのスピーカーさんに参考文献を教えていただきました。

各書籍のリンクを載せます。

おわりに

関数の切り出し方に悩む人は多いと思いますが、この記事が少しでも指標になると嬉しいです。

参考リンク

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift】selfが呼ばれる優先順位

メンバ変数とローカル変数が同じ名前の場合、
selfは下記のような優先順位で呼ばれます

コード

class Sample {
    var text: String = "aaa"

    func test(){
        let text: String = "bbb"
        print(self.text)
        print(text)
    }

}

let sample = Sample()
sample.test()

出力結果

aaa
bbb

selfがある場合はメンバ変数が、
selfがない場合はローカル変数が呼ばれます

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iPhoneでカワボになりたい

はじめに

Cで実装されたWorld VocoderのSwiftのwrapperを作りました。
リポジトリはこちら → https://github.com/fuziki/WorldInApple
動作の様子

WorldInApple

サンプルプロジェクト

  • worldをサブモジュールとして組み込んでいるため、--recurse-submodulesオプションをつけてクローンします。
  • サンプルプロジェクトはchartsを使っているので、cathageを使ってインストールします。
  • xcworkspaceの方(白い方)を開きます。
git clone --recurse-submodules https://github.com/fuziki/WorldInApple
cd WorldInApple/Examples/Example-iOS/
carthage update --platform iOS
cd ../../
open WorldInApple.xcworkspace

機能

概要

AVAudioPCMBufferのピッチとフォルマントを変化させることができます。
AVAudioPCMBufferはiOSでPCMを扱うことができるクラスで、PCMのバイナリとそのフォーマットを格納しています。
Worldには64bit浮動小数点、48kHz、1chのPCMデータを入力します。指定のフォーマットへの変換はAVAudioConverterを使用します。

AVAudioPCMBufferのピッチとフォルマントを変える

1. WorldInAppleのインスタンスを作成する

  • x_lengthにはAVAudioPCMBufferのframe長を設定します。
    (例えば、48kHzの38400frame分の場合は38400frame/48kHz=0.8sec)
let worldInApple = WorldInApple(fs: 48000, frame_period: 5, x_length: 38400)

2. ピッチとフォルマントを設定する

  • ピットとフォルマントの移動分を設定します。
  • 1より大きいと高くなり、1より小さい低くなります。
  • ピッチは声の音自体の高さであり、フォルマントは声質です。
worldInApple.set(pitch: Double(1.2)) 
worldInApple.set(formant: Double(1.8))

3. AVAudioPCMBufferを入力する

  • convの結果としてAVAudioPCMBufferを受け取ります。
  • 0.8sec分の処理に0.4sec~0.7secほど要するため非同期で実行することをオススメします。
let result = worldInApple.conv(buffer: buffer)

イコライザー

サンプルプロジェクトにはイコライザを搭載しています。
worldの変換後の音に対してイコライザ処理ができます。
スライドバーが上にあるほど低音域で、下にあるほど高音域です。
低音域のゲインを小さくすることでノイズを低減する効果がある(気がします)。
IMG_2793.PNG

おわりに

WorldのSwiftのwrapperを実装しました。
swiftがcの関数の呼び出しが可能なため、比較的簡単に高品質なvocoderを実装ができました。
簡単にiOS実機とmacのエディタで使えるアセットを作成するツールを作っていています
https://fzkqi.hatenablog.com/entry/2020/02/12/235202
今後はこれを使って、WorldのUnityのNative Pluginを作りたいなと考えています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Swift5]ビリビリ動画にあるようなスクロールできてタッチできるメニューバー

はじめに

筆者は中国大手の動画共有サイト「bilibli」のUIを気に入っているのですが、bilibiliで実際に取り入れられている「スクロールできてタッチできるメニューバー」を実装したので共有したいと思います。

スクロール操作だけのメニューバーについての記事はよく見かけますが、タッチ操作ができるようなものはあまり見かけないので、そのような実装をしてみたい人にとっては役に立つ記事になるのではないでしょうか。
(タッチ操作を可能にすることで、スクロールメニューバーの選択バーのアニメーションスクロールビューのタッチイベントを可能にしたりなど難易度が少し上がるからでしょうか?)

目次

  • 環境
  • 実行例
  • 考え方
    • 画面構成
    • 実装について
      • UIScrollViewを作成する
      • UIScrollViewにUILabelを貼り付ける
      • UIScrollViewのタッチイベントを受け取れるようにしタッチした際の処理をする
      • UIScrollViewのスクロールイベント関数内でUIScrollViewBarについて処理をする
  • ソースコード
  • 終わりに

環境

  • Xcode 11.2.1
  • Swift5

実行例

samplescrollview.gif

考え方

ロジックはそこまで必要になりませんが知識の幅は必要になるかなと思います。

画面構成

画面の構成は以下のようになります。

Diagram.png

UIView(黄)UIScrollViewUIImageViewUIView(紫)が貼り付けられていて、UIScrollViewUILabelが貼り付けられています。

UILabelがメニューバーのメニューとなっていて、UIView(紫)がスクロールメニューバーの選択バーとなっています。

実装について

今回ちょっとしたコツがいるのは以下の3つになります。

ポイント
スクロールビューをタッチできるようにすること。
選択しているメニューの下に表示するバーの実装。
二つのスクロールビュー操作をひとつのスクロールイベント関数で処理わけする。

また、全体的な処理の流れは以下になります。

順番 処理内容
1 UIScrollView、UIView(スクロールビューのバー)を作成する
2 UIScrollViewにUILabelを貼り付ける
3 UIScrollViewのタッチイベントを受け取れるようにしタッチした際の処理をする
4 UIScrollViewのスクロールイベント関数内でUIScrollViewBarについて処理をする

1.UIScrollViewを作成する

まずはUIScrollViewを作成しましょう。

private func createHeaderView() {
        let displayWidth: CGFloat! = self.view.frame.width
        // 上に余裕を持たせている(後々アニメーションなど追加するため)
        myHeaderView = UIView(frame: CGRect(x: 0, y: -230, width: displayWidth, height: 230))
        myHeaderView.alpha = 1
        myHeaderView.backgroundColor = UIColor(red: 142/255, green: 237/255, blue: 220/255, alpha: 1)
        myTableView.addSubview(myHeaderView)
        scrollView = UIScrollView(frame: CGRect(x: 0, y: 200, width: displayWidth, height: 30))
        scrollView.bounces = false
        scrollView.alwaysBounceHorizontal = false
        scrollView.alwaysBounceVertical = false
        scrollView.showsVerticalScrollIndicator = false
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.backgroundColor = UIColor(red: 238/255, green: 142/255, blue: 160/255, alpha: 1)
        makeScrollMenu(scrollView: &scrollView)
        myHeaderView.addSubview(scrollView)
        scrollViewBar = UIView(frame: CGRect(x: 0, y: 225, width: 70, height: 5))
        scrollViewBar.backgroundColor = UIColor(red: 142/255, green: 237/255, blue: 220/255, alpha: 0.8)
        myHeaderView.addSubview(scrollViewBar)
        let image = UIImageView(frame: CGRect(x: (displayWidth-100)/2, y: 100, width: 100, height: 100))
        if bilibili {
            image.image = UIImage(named: "bili2")
        } else {
            image.image = UIImage(named: "bili")
        }
        myHeaderView.addSubview(image)
    }

scrollViewではBounceしないように設定してます。
また、スクロールバーも非表示の設定にしてます。
makeScrollMenu(scrollView: &scrollView)でラベルを貼り付けていきます。

スクロールビューの選択バーもここで作成しています。(ScrollViewBar: UIView)

動くヘッダービューに関してはこちらの記事では説明しません。興味のある人はこちらをご覧ください。

2.UIScrollViewにUILabelを貼り付ける

UIScrollViewにUILabelを貼り付けるのは以下のように表現されます。

    func makeScrollMenu(scrollView: inout(UIScrollView)) {
        let menuLabelWidth:CGFloat = 70
        let titles = Data.TitleMenu
        let menuLabelHeight:CGFloat = scrollView.frame.height
        var X: CGFloat = 0
        var count = 1
        for title in titles {
            let scrollViewLabel = UILabel()
            scrollViewLabel.textAlignment = .center
            scrollViewLabel.frame = CGRect(x:X, y:0, width:menuLabelWidth, height:menuLabelHeight)
            scrollViewLabel.text = title
            scrollViewLabel.isUserInteractionEnabled = true
            scrollViewLabel.tag = count
            scrollView.addSubview(scrollViewLabel)
            X += menuLabelWidth
            count += 1
            scrollViewLabelArray.append(scrollViewLabel)
        }

        changeColorScrollViewLabel(tag: 1)

        scrollView.contentSize = CGSize(width:X, height:menuLabelHeight)
    }

UILabelの高さはUIScrollView同じ高さに、横幅は好きな大きさに設定します。
ラベル同士を被らないように並べていくのは横幅分ずらして設置していくだけです。
また、タッチした際に識別できるようにtagを設定していきます。
そしてタッチできるようにisUserInteractionEnabledtrueにします。

changeColorScrollViewLabel(tag: Int)で選択されているラベルの色を変えます。
scrollViewLabelArray: [UILabel]に作成したラベルを追加していますが、それはchangeColorScrollViewLabel()で使うためです。

3.UIScrollViewのタッチイベントを受け取れるようにしタッチした際の処理をする

UIScrollViewそのままではタッチイベントを受け取れません
なのでextensionでタッチイベントを受け取れるように以下のように記述します。

    override open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.next?.touchesBegan(touches, with: event)
    }

これでスクロールビューからタッチイベントを取得できるようになったので、次はタッチイベント関数での処理を記述していきましょう。

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.next?.touchesBegan(touches, with: event)
        for touch: AnyObject in touches {
            let t: UITouch = touch as! UITouch
            guard t.view is UILabel else {
                return
            }

            switch t.view!.tag {
            case 1:
                print(1)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)

                changeColorScrollViewLabel(tag: 1)

                Data.setIndex(v: 0)
                myTableView.reloadData()
            case 2:
                print(2)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)

                changeColorScrollViewLabel(tag: 2)

                Data.setIndex(v: 1)
                myTableView.reloadData()
            case 3:
                print(3)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)

                changeColorScrollViewLabel(tag: 3)

                Data.setIndex(v: 2)
                myTableView.reloadData()
            case 4:
                print(4)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)

                changeColorScrollViewLabel(tag: 4)

                Data.setIndex(v: 3)
                myTableView.reloadData()
            case 5:
                print(5)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)

                changeColorScrollViewLabel(tag: 5)

                Data.setIndex(v: 4)
                myTableView.reloadData()
            case 6:
                print(6)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)

                changeColorScrollViewLabel(tag: 6)

                Data.setIndex(v: 5)
                myTableView.reloadData()
            default:
                break
            }
        }
    }

はじめにguard t.view is UILabel else {return}UILabel以外のタッチに対しては無視するようにしています。
状況によって適切な条件を用意してあげてください。

それ以降はSwitch文でラベルのtagで場合分けしています。
では、ラベルをタッチした際に行う処理を見ていきましょう。
はじめにUIView.animate()scrollViewBarに対してアニメーション処理をしているのがわかりますね。
これは選択バーの位置を選択されたメニューの位置に動かすような操作をしています。

self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x

上のコードのように選択されたラベルのx座標からスクロールビューの現在のx座標を引いた値をスクロールビューバーのx座標にしています。
(これによりスクロールビューがどのような位置にあってもUILabelにくっついたように表示することができます)

これは以下の図を見ると理解しやすいと思います。
Untitled Diagram (1).png

次に選択されたメニューの色を変えます。
コード内ではchangeColorScrollViewLabel(tag: Int)を呼び出して色を変えています。
この関数は以下のように表現されています。

    private func changeColorScrollViewLabel(tag: Int) {
        for label in scrollViewLabelArray {
            if label.tag == tag {
                label.textColor = UIColor(red: 142/255, green: 237/255, blue: 220/255, alpha: 1)
            } else {
                label.textColor = .white
            }
        }
    }

最後にTableViewで使用するデータを切り替える処理を行います。
Data.setIndex(v: Int)でデータを切り替え、myTableView.reloadData()で更新していますね。
Dataは構造体dataのインスタンスです。
構造体dataは以下のように表現されています。


    struct data {
        let TitleMenu = ["アニメ","ドラマ","映画","ニュース","漫画","生放送"]
        var index = 0

        mutating func setIndex(v: Int) {
            index = v
        }

        func getTitle() -> String {
            return TitleMenu[index]
        }
    }

4.UIScrollViewのスクロールイベント関数内でUIScrollViewBarについて処理をする

スクロールイベント関数は以下のように表現されています。

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        switch scrollView {
        case self.scrollView:
            scrollingViewBar(scrollView: scrollView)
        case self.myTableView:
            scrollingMyTableView(scrollView: scrollView)
        default:
            break
        }
    }

今回このスクロールイベント関数を呼び出されるScrollView2つあります。
scrollViewmyTableViewですね。
なのでスクロールイベント関数がどっちのScrollViewによって呼び出されたものなのかを判断して処理を分けてあげないと不具合が起きてしまうことがあります。(実は筆者はここでハマりました。笑)

ではscrollViewがスクロールされた際に呼び出されている関数scrollingViewBarを見ていきましょう。

    private func scrollingViewBar(scrollView: UIScrollView) {
        scrollViewBar.frame.origin.x += (lastContentOffsetX - scrollView.contentOffset.x)
        lastContentOffsetX = scrollView.contentOffset.x
    }

lastContentOffsetXは前に呼び出された際のスクロールビューの位置を保存しています。
lastContentOffsetX - scrollView.contentOffset.xでスクロール量を求めることができます。
scrollViewBarをスクロールした分だけ横にずらせればいいので、scrollViewBarのx座標にスクロール量を足すだけですね。

参考までにそれを説明した図を再掲しておきます。
Untitled Diagram (1).png

ソースコード

今まで説明したコードを抜粋して載せます。
Githubにサンプルを載せておくので参考にしてみてください。
https://github.com/Hajime-Ito/SampleScrollMenuBar

var scrollView: UIScrollView!
var scrollViewBar: UIView!
var myHeaderView: UIView!
var lastContentOffset: CGFloat = 0
var lastContentOffsetX: CGFloat = 0
var scrollViewLabelArray: [UILabel] = []

private func createHeaderView() {
        let displayWidth: CGFloat! = self.view.frame.width
        // 上に余裕を持たせている(後々アニメーションなど追加するため)
        myHeaderView = UIView(frame: CGRect(x: 0, y: -230, width: displayWidth, height: 230))
        myHeaderView.alpha = 1
        myHeaderView.backgroundColor = UIColor(red: 142/255, green: 237/255, blue: 220/255, alpha: 1)
        myTableView.addSubview(myHeaderView)
        scrollView = UIScrollView(frame: CGRect(x: 0, y: 200, width: displayWidth, height: 30))
        scrollView.bounces = false
        scrollView.alwaysBounceHorizontal = false
        scrollView.alwaysBounceVertical = false
        scrollView.showsVerticalScrollIndicator = false
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.backgroundColor = UIColor(red: 238/255, green: 142/255, blue: 160/255, alpha: 1)
        makeScrollMenu(scrollView: &scrollView)
        myHeaderView.addSubview(scrollView)
        scrollViewBar = UIView(frame: CGRect(x: 0, y: 225, width: 70, height: 5))
        scrollViewBar.backgroundColor = UIColor(red: 142/255, green: 237/255, blue: 220/255, alpha: 0.8)
        myHeaderView.addSubview(scrollViewBar)
        let image = UIImageView(frame: CGRect(x: (displayWidth-100)/2, y: 100, width: 100, height: 100))
        if bilibili {
            image.image = UIImage(named: "bili2")
        } else {
            image.image = UIImage(named: "bili")
        }
        myHeaderView.addSubview(image)
    }

    private func updateHeaderView() {
        let displayWidth: CGFloat! = self.view.frame.width
        self.myHeaderView.subviews[2].removeFromSuperview()
        let image = UIImageView(frame: CGRect(x: (displayWidth-100)/2, y: 100, width: 100, height: 100))
        if bilibili {
            image.image = UIImage(named: "bili2")
        } else {
            image.image = UIImage(named: "bili")
        }
        myHeaderView.addSubview(image)
    }

    func addHeaderViewGif() {
        let displayWidth: CGFloat! = self.view.frame.width
        let image = UIImageView(frame: CGRect(x: (displayWidth-100)/2, y: 100, width: 100, height: 100))
        if bilibili {
            image.loadGif(name: "bili2")
        } else {
            image.loadGif(name: "bili")
        }
        myHeaderView.addSubview(image)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            self.myHeaderView.subviews[2].removeFromSuperview()
        }
    }

    func makeScrollMenu(scrollView: inout(UIScrollView)) {
        let menuLabelWidth:CGFloat = 70
        let titles = Data.TitleMenu
        let menuLabelHeight:CGFloat = scrollView.frame.height
        var X: CGFloat = 0
        var count = 1
        for title in titles {
            let scrollViewLabel = UILabel()
            scrollViewLabel.textAlignment = .center
            scrollViewLabel.frame = CGRect(x:X, y:0, width:menuLabelWidth, height:menuLabelHeight)
            scrollViewLabel.text = title
            scrollViewLabel.isUserInteractionEnabled = true
            scrollViewLabel.tag = count
            scrollView.addSubview(scrollViewLabel)
            X += menuLabelWidth
            count += 1
            scrollViewLabelArray.append(scrollViewLabel)
        }

        changeColorScrollViewLabel(tag: 1)

        scrollView.contentSize = CGSize(width:X, height:menuLabelHeight)
    }

    private func changeColorScrollViewLabel(tag: Int) {
        for label in scrollViewLabelArray {
            if label.tag == tag {
                label.textColor = UIColor(red: 142/255, green: 237/255, blue: 220/255, alpha: 1)
            } else {
                label.textColor = .white
            }
        }
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.next?.touchesBegan(touches, with: event)
        print("touch")
        for touch: AnyObject in touches {
            let t: UITouch = touch as! UITouch
            guard t.view is UILabel else {
                return
            }
            switch t.view!.tag {
            case 1:
                print(1)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)

                changeColorScrollViewLabel(tag: 1)

                Data.setIndex(v: 0)
                myTableView.reloadData()
            case 2:
                print(2)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)

                changeColorScrollViewLabel(tag: 2)

                Data.setIndex(v: 1)
                myTableView.reloadData()
            case 3:
                print(3)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)

                changeColorScrollViewLabel(tag: 3)

                Data.setIndex(v: 2)
                myTableView.reloadData()
            case 4:
                print(4)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)

                changeColorScrollViewLabel(tag: 4)

                Data.setIndex(v: 3)
                myTableView.reloadData()
            case 5:
                print(5)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)

                changeColorScrollViewLabel(tag: 5)

                Data.setIndex(v: 4)
                myTableView.reloadData()
            case 6:
                print(6)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)

                changeColorScrollViewLabel(tag: 6)

                Data.setIndex(v: 5)
                myTableView.reloadData()
            default:
                break
            }
        }
    }

}

    private func scrollingViewBar(scrollView: UIScrollView) {
        scrollViewBar.frame.origin.x += (lastContentOffsetX - scrollView.contentOffset.x)
        lastContentOffsetX = scrollView.contentOffset.x
    }


    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        switch scrollView {
        case self.scrollView:
            scrollingViewBar(scrollView: scrollView)
        case self.myTableView:
            scrollingMyTableView(scrollView: scrollView)
        default:
            break
        }
    }

//MARK: --

extension UIScrollView {
    override open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.next?.touchesBegan(touches, with: event)
    }
}

終わりに

今回は動かせるヘッダーとしてスクロールできるメニューバーをくっ付けたので、普通にメニューバーを作るよりも難易度が少し高かったのかなと思います。
しかし今回実装したスクロールできてタッチで選択できるメニューバーはよく見かけるくらい使われる実装だと思うので、理解しておいて損はないはずです。

それにしても、見たものを再現するのは暇つぶしには最適だなぁと思いました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む