20200217のiOSに関する記事は6件です。

iPhoneにプッシュ通知をしたかっただけなのに

プッシュ通知したかった

普段は社内用のシステム開発を担当してます。
ある日「社内のメンバーが気づけるように、内部向けの通知機能を開発して欲しい」という要望をいただきました。
・・・頑張ります!ということでWebとAndroidにはFCMからプッシュ通知できるようにして解決しました!!!!!!!!

・・・と思ったらおやおや?iPhoneでも受け取りたい?
いやいや、一筋縄ではいかないのですよ。
iPhoneはわがままなので、APNsで証明書を発行した端末でしか通知を受け取れないんですよ;;;;
と半泣きになったので、代替え案をたくさん調べたのでメモに残しておきます。

Slackで通知?

  • 通知したくなったらSlackでお知らせしてあげる方法です

メリット

  • 追加の費用を発生させずに通知を送ることができます
  • 全デバイスに対応可能で開発工数も極小です
  • 新たにアプリのインストール作業が不要です

デメリット

  • 気づけないから嫌だと言われました
  • 大量に通知を流す都合上、Slackのrate limitで送信が制限される可能性があります
  • 明確な基準はないが、公式によると秒間1通で短時間ならバースト可能程度の記載がありました
  • 現在の運用上、Slackでメンションがあっても気づかないパターンがあります
  • ユーザが増減する度に個人Slackチャンネルを追加・削除した上でシステム上で作業する管理・運用工数が発生します

ネイティブアプリを開発して限定配布?

  • 通知を受け取るためだけのネイティブアプリを開発する方法です(FCM→受信用アプリ)
  • 審査を通さなければどんなアプリでも良いよね、という発想w

メリット

  • 通信制限にかかる恐れがないです
  • 利用ユーザが増えても月額の費用がほぼブレない
  • 将来的な機能拡張が見込めます

デメリット

  • 限定配布するため、100台までか、Apple の Enterprise 登録する必要があります
  • iPhoneはアプリのインストール作業が必要です

メールアプリの受信設定で通知?

  • iPhoneにデフォルトでインストールされている連絡帳アプリとメールアプリでVIP設定をしておくと、特定のアドレスからの受信時だけ端末にプッシュ通知を飛ばすことができます(SMS)

https://support.apple.com/ja-jp/guide/mail/mail40589/mac

メリット

  • メールの機能が使えるようになるので、通知の種類がたくさんある場合は分類分けして集計することが可能です

デメリット

  • 送信用のドメインを管理する必要があります
  • フィルターをかけたり設定をうまくしないと、受信箱が大変なことになります
  • プッシュ通知を押したら指定のURLに飛ばす、といったことはできません

IFTTTアプリ?

IFTTTというサービスとサービスを連携してくれるサービスのwebhookでNotificationを発動させるイベントを作成する方法です。もしWebhookを叩かれたら、URLKeyを指定した端末にプッシュ通知してーというイベントを作成すると通知させることができます

https://www.digitaltrends.com/cool-tech/what-is-ifttt-and-how-does-it-work/

メリット

  • 無料で何通でもプッシュ通知を送信できます
  • 実際に、1日で1500通飛ばしてみたんですが問題ありませんでした

デメリット

  • 100通を一斉送信すると、途中の60件以降が届かないことがあってエラーにもなってないから不思議?ってことがありました→特にエラーは出ないため、連続で何通も確実に届けたい時は不向きかも
  • IFTTTアプリをダウンロードする必要があります
  • 以前は作成したイベントをシェアする機能がありましたが、数年前からなくなってしまったので、「WebhookがきたらNotificationを発動させる」というイベントの設定を、プッシュを受け取りたい全員が設定する必要があります

外部サービスの利用?

メリット

本業に専念できます

デメリット

  • お高い。月額50,000円や、通数によって〜50万件:39800円などと料金が変動するプランのサービスもありました。まとまっているサイトを発見しました。これ以外にもたくさんサービスがあるみたいです。https://liskul.com/push-notification-25520
  • 内製化したい場合は不向きです

SMSで通知っぽく見せかける?

メリット

  • 全デバイスで対応可能です
  • アプリのインストール作業が不要です

デメリット

Softbank Mobile Corp / 440 000        $0.08575
Softbank Mobile Corp / 441 001        $0.09792

iPhoneにプッシュ通知送るだけなのにこんなに大変なんて思ってませんでした;
最終的にどうするかはまだ決まっていないので、引き続き頑張ります><

  • このエントリーをはてなブックマークに追加
  • 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で続きを読む

【iOS】自由にUndo/Redoできるテキスト入力欄を作る

UndoManagerを使ってUITextViewにUndo/Redo機能を追加し、保存タイミングの制御も行う例を紹介します。

※ ここで使うUndoManagerは特にUITextView専用というものではなく、UITextFiledその他何にでも使えます
※ 「保存タイミングの制御」とは「1文字ずつ保存」「変換中は記録しない」「10秒に1度保存」などのことです

コード例

import UIKit
class MemoEditorViewController: UIViewController {
    // [1] UndoManagerと「直前の内容」(Undo時に戻したい内容)
    var textUndoManager: UndoManager = UndoManager()
    private var editingText: NSAttributedString = NSAttributedString()

    // [2] UITextViewとUndo/Redoボタン
    @IBOutlet weak var textArea: UITextView! {
        didSet { textArea.delegate = self }
    }
    @IBAction func tapUndoButton(_ sender: Any) {
        undo()
    }
    @IBAction func tapRedoButton(_ sender: Any) {
        redo()
    }

    // [5] Undo/Redoの呼び出し
    private func undo() {
        textUndoManager.undo()
        editingText = textArea.attributedText
    }
    private func redo() {
        textUndoManager.redo()
        editingText = textArea.attributedText
    }
    // [3] 直前の内容をUndo登録し、現在の内容をRedo登録する
    func registerUndo(text: NSAttributedString) {
        if textUndoManager.isUndoRegistrationEnabled {
            textUndoManager.registerUndo(withTarget: self, handler: { _ in
                if let currentText = self.textArea.attributedText { self.registerUndo(text: currentText) }
                self.textArea.attributedText = text
            })
        }
    }
}
extension MemoEditorViewController: UITextViewDelegate {
    // [4] 条件を満たした時にUndoを登録
    func textViewDidChange(_ textView: UITextView) {
        if (textView.markedTextRange == nil) {
            registerUndo(text: editingText)
            editingText = textArea.attributedText
        }
    }
}

[1] UndoManagerと「直前の内容」(Undo時に戻したい内容)

Undo/Redoの記録には、UndoManagerに「現在の内容」と「直前の内容」を渡す必要があります。
ので、UndoManagerと「直前の内容」の変数を用意しましょう。
「現在の内容」はUITextViewから直接とれるので不要。

    var textUndoManager: UndoManager = UndoManager()
    private var editingText: NSAttributedString = NSAttributedString()

[2] UITextViewとUndo/Redoボタン

Undo/Redo用ボタン2つとUITextViewを用意。
image.png
今回はStoryboardを使うのでOutlet。
ついでにTextViewのdelegateをセットし、ボタンの呼び出しundo() / redo()も仮で書いておきましょう。

    @IBOutlet weak var textArea: UITextView! {
        didSet { textArea.delegate = self }
    }
    @IBAction func tapUndoButton(_ sender: Any) {
        undo()
    }
    @IBAction func tapRedoButton(_ sender: Any) {
        redo()
    }

[3] 直前の内容をUndo登録し、現在の内容をRedo登録する

UndoManagerに、「直前の内容」に戻すというUndoを登録します。
さらにUndo時にそのUndo(つまりRedo)を登録します。ここでは「現在の内容」に戻すという操作です。
https://developer.apple.com/documentation/foundation/undomanager

    // 引数には「直前の内容」を渡す
    func registerUndo(text: NSAttributedString) {
        if textUndoManager.isUndoRegistrationEnabled {
             // Undo時にやることを登録
            textUndoManager.registerUndo(withTarget: self, handler: { _ in
                 // Undo中のUndo登録はRedo登録となる(「現在の内容」に戻すという操作を登録)
                if let currentText = self.textArea.attributedText { self.registerUndo(text: currentText) }
                 // 「直前の内容」に戻す
                self.textArea.attributedText = text
            })
        }
    }

[4] 条件を満たした時にUndoを登録

Undoを登録する条件を決めます。
この例はUITextViewDelegateを使い、内容が変更されたら登録するというオーソドックスな方法です。
保存したら「直前の内容」も更新します。

    func textViewDidChange(_ textView: UITextView) {
        if (textView.markedTextRange == nil) {
            registerUndo(text: editingText)
            editingText = textArea.attributedText
        }
    }

markedTextRange == nilは「テキストが選択状態でない」ですが、ここでは「日本語などの変換中ではない」という意味合いで使っています。
これで変換中はUndoを保存しません。

registerUndo()を他の方法で呼び出せば自由なUndo登録が可能です。
ボタンを設置したり、10秒に1回にしたり、変更カウンターを作って10溜まったらなど。

[5] Undo/Redoの呼び出し

Undo/Redoボタンを完成させます。
UndoManagerのundo() / redo()を呼ぶだけ。このとき「直前の内容」も更新。

    private func undo() {
        textUndoManager.undo()
        editingText = textArea.attributedText
    }
    private func redo() {
        textUndoManager.redo()
        editingText = textArea.attributedText
    }

UndoManagerが動作そのものを記録/呼出する仕様であったお陰で、呼び出しはえらく簡単に終わりました。

おわりに

他にもcanUndoなどを使ってボタン有効性の表示を切り替えたり、長押しで早送り可能にするなど色々やりこみ要素があります。
この記事が導入の助けになれば幸いです。

  • このエントリーをはてなブックマークに追加
  • 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で続きを読む