20200918のSwiftに関する記事は8件です。

【Swift5】FSCalendarのカレンダー表示モードを変更する

はじめに

カレンダー付きToDoアプリを制作する際にFSCalendarというライブラリを使用しましたので備忘録として投稿します。
初学者ですので訂正点ございましたら、ご指摘よろしくお願いします。

概要

FSCalendarではカレンダーの表示モードを月表示週表示に任意で変更することができます。
制作したアプリの用途に絡めると「ボタンのタップイベントにて表示モードを変更する」になります。

また、FSCalendarの導入に関しましてはこちらを参照ください。

実行環境

【Xcode】 Version 11.7
【Swift】 version 5.2.4
【CocoaPods】version 1.9.3
【FSCalendar】version 2.8.1

実装後の画面

sample.gif

実装コード

全体のコードになります。
サンプルコードではなく、自作アプリのコードになりますので関連箇所を抜粋しております。
また、FSCalendarのDelegateとDataSourceはstoryboard上で追加しております。

MainViewController.swift
import UIKit
import FSCalendar
import CalculateCalendarLogic
# ・・・省略・・・

class MainViewController: UIViewController {

    @IBOutlet weak var calendar: FSCalendar!
    @IBOutlet weak var calendarHeight: NSLayoutConstraint!
    # ・・・省略・・・

    override func viewDidLoad() {
        super.viewDidLoad()

        // calendarの曜日部分を日本語表記に変更
        calendar.calendarWeekdayView.weekdayLabels[0].text = "日"
        calendar.calendarWeekdayView.weekdayLabels[1].text = "月"
        calendar.calendarWeekdayView.weekdayLabels[2].text = "火"
        calendar.calendarWeekdayView.weekdayLabels[3].text = "水"
        calendar.calendarWeekdayView.weekdayLabels[4].text = "木"
        calendar.calendarWeekdayView.weekdayLabels[5].text = "金"
        calendar.calendarWeekdayView.weekdayLabels[6].text = "土"
        // calendarの曜日部分の色を変更
        calendar.calendarWeekdayView.weekdayLabels[0].textColor = .systemRed
        calendar.calendarWeekdayView.weekdayLabels[6].textColor = .systemBlue

        # ・・・省略・・・
    }

    // calendarの表示形式変更
    @IBAction func changeButtonAction(_ sender: Any) {
        if calendar.scope == .month {
            calendar.setScope(.week, animated: true)
            changeButton.title = "月表示"
            // calendarを更新
            calendar.reloadData()
        } else if calendar.scope == .week {
            calendar.setScope(.month, animated: true)
            changeButton.title = "週表示"
            // calendarを更新
            calendar.reloadData()
        }         
    }
    # ・・・省略・・・
}

extension MainViewController: FSCalendarDelegate, FSCalendarDataSource, FSCalendarDelegateAppearance {
    # ・・・省略・・・
    func calendar(_ calendar: FSCalendar, boundingRectWillChange bounds: CGRect, animated: Bool) {
        calendarHeight.constant = bounds.height
        self.view.layoutIfNeeded()
    }
    # ・・・省略・・・
}

実装方法(storyboardにて)

1.storyboardにFSCalendarを配置する

  • 配置方法の手順はこちらを参照下さい。

2.配置したFSCalendarにAutoLayoutの制約を付ける

  • 左右0を設定します。(※1)
  • Heightを設定します。今回は350にしました。この制約が大切なので忘れずに設定して下さい。(※2)
  • Constraint to marginsにチェックが入っていると、余計なmarginが入ってしまうので外しておきましょう。(※3)

スクリーンショット 2020-09-16 23.58.31.png

実装方法(コードにて)

1.FSCalendarクラスのcalendarを定義し、storyboardで紐づけする

@IBOutlet weak var calendar: FSCalendar!

2.NSLayoutConstraintクラスのcalendarHeightを定義する

  • コードでAutoLayoutの制約を付ける場合、NSLayoutConstraintクラスによって定義することができます。
@IBOutlet weak var calendarHeight: NSLayoutConstraint!

3.AutoLayoutで制約の設定をしたHeightと2.で定義したcalendarHeightをstoryboardで紐づけする

  • 添付写真はcalendar Heightと紐づけされた後の表記になっております。紐付ける前はheight = 350となります。

スクリーンショット 2020-09-17 0.28.49.png

4.ボタンのタップイベントでカレンダーの表示モードを変更する

  • 以下のコードを用いることで表示モードを変更することができます。
//月ごとの表示にしたい時         
calendar.setScope(.month, animated: true)
//週ごとの表示にしたい時
calendar.setScope(.week, animated: true)
  • 上記をボタンのタップイベントに絡めていきます。説明が抜けておりますがchangeButtonActionは「実装後の画面」で操作しているボタンになります。
// calendarの表示形式変更
@IBAction func changeButtonAction(_ sender: Any) {
    if calendar.scope == .month {
        calendar.setScope(.week, animated: true)
        changeButton.title = "月表示"
        // calendarを更新
        calendar.reloadData()
    } else if calendar.scope == .week {
        calendar.setScope(.month, animated: true)
        changeButton.title = "週表示"
        // calendarを更新
        calendar.reloadData()
    }         
}

5.Viewの大きさのリサイズ

  • FSCalendarは月表示が標準で、コードで表示モードを週表示に切り替えると、レイアウトの自動調整がかかりません。この問題は下記コードを追加することにより解決することができます。
func calendar(_ calendar: FSCalendar, boundingRectWillChange bounds: CGRect, animated: Bool) {
    calendarHeight.constant = bounds.height
    self.view.layoutIfNeeded()
}

補足(カレンダー表示について)

  • 週の表示名を変更したい場合は下記コードを追加することで「実装後の画面」のようになります。
// calendarの曜日部分を日本語表記に変更
calendar.calendarWeekdayView.weekdayLabels[0].text = "日"
calendar.calendarWeekdayView.weekdayLabels[1].text = "月"
calendar.calendarWeekdayView.weekdayLabels[2].text = "火"
calendar.calendarWeekdayView.weekdayLabels[3].text = "水"
calendar.calendarWeekdayView.weekdayLabels[4].text = "木"
calendar.calendarWeekdayView.weekdayLabels[5].text = "金"
calendar.calendarWeekdayView.weekdayLabels[6].text = "土"
// calendarの曜日部分の色を変更
calendar.calendarWeekdayView.weekdayLabels[0].textColor = .systemRed
calendar.calendarWeekdayView.weekdayLabels[6].textColor = .systemBlue
  • 日付のフォーマットはstoryboardで変更することができます。

スクリーンショット 2020-09-18 21.57.16.png

参考

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

Swift のOptionalをUnwrapする5つの方法 4個目はお勧め

1. Force Unwrapping

optional!

2. Check for ni value

if optional != nil {
    optional!
}

3. Optional Binding

if let safeOptional = optional{
    safeOptional
}

4. Nil Coalescing Operator ??

optional ?? defaultValue

5. Optional Chaining

optional?.property
optional?.method()
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

クロージャと、@escapingと、循環参照。

image.png

 クロージャ

 使い方

変数引数に、関数の処理を直接代入する。

 使う理由

最近のプログラミング言語では、
引数に関数を入れるとか、戻り値に関数を入れるとかが当たり前になっているので、
そういうときにクロージャを使うと綺麗に記述できるので、みんな嬉しい。

  • 引数として使うパターンが多い

 名前の由来

Closure closes over var.

 コード

 let, varに、関数の処理を直接代入

// MARK: - let, varに、関数の処理を直接代入

// 定数closureが、恰も関数のように扱える
let closure = { () -> () in print("Hello World!")}
closure()

let closure_2 = { () -> Void in print("hamburger")}
closure_2()
// 戻り値がVoid型、引数がない場合は、(引数) -> 戻り値の型 in を省略可
let closure_3 = { print("Yeah!") }
closure_3()

// 型を指定
let closure_4: (Int, Int) -> Void = { (num1: Int, num2: Int) -> Void in print(num1 + num2) }
// 型推論ってやつ
let closure_5 = { (num1: Int, num2: Int) -> Void in print(num1 + num2) }
closure_4(100, 199)
closure_5(100, 199)

// 因みに、closure_4 は num1, num2を省略可。

// 内部引数名を省略 -> 「$」を使用。
// $0 は最初の引数を表し、$1 は2番目の引数を表します。
let closure_6: (Int, Int, Int, Int) -> Void = {
    print ($0 - $1 + $2 + $3)
}
closure_6(30, 20, 100, 10) // 120

 型推論とは?

型の指定をしなくても、代入した値に応じて値の型を推論してくれる機能。
つまり、Swiftは変数の宣言時に型の指定を省略できる。( -> むしろ推奨。)

 内部引数とは?

 関数を呼び出すときは、外部引数名を利用します。

 普段 私たちは、内部引数名外部引数名として、関数を呼び出しています。
 違いはこちら


 クロージャを引数として、関数を実行

  • 引数として使うパターンが多い
// MARK: - クロージャを引数として、関数を実行

func closureTest(num1: Int, num2: Int, closure: (Int, Int) -> Int) { print(closure(num1, num2)) }

// return文が1行のみの場合には「return」は省略可
closureTest(num1: 300, num2: 5000, closure: { (num1, num2) -> Int in return num1 + num2 })
closureTest(num1: 300, num2: 5000, closure: { (num1, num2) -> Int in num1 + num2 })

// 引数の型を入力しないと、こうなるので注意。
// closureTest(<#T##<<error type>>#>, <#T##<<error type>>#>, <#T##<<error type>>#>)

// トレーリング クロージャ (= Trailing Closure)
closureTest(num1: 100, num2: 400) { (num1, num2) -> Int in
    num1 + num2 // return省略
}

 Trailing Closureとは?

関数の引数のうち 最後の引数がクロージャの場合、
クロージャを( )の外に書くことができる。

func testprint(str1: String, closure: (String) -> Void) {
    closure("僕の名前は\(str1)です。")
}

// トレーリングクロージャの場合 -> 美しい
testprint(str1: "玄邪 太郎") { string in
    print(string)   
}

// 通常のクロージャの場合 -> 可読性が悪い...
testprint(str1: "玄邪 太郎",{ string in
    print(string)   
})
  • クロージャが引数だと、{ }の外に( )を包まないといけないので、 可読性が悪くなる。(-> そこで、Trailing Closureが考案)

 クロージャの、基本的な性質2つ

  • ?クロージャーは関数の引数変数として使える
  • ?クロージャーは自分が定義されたスコープをキャプチャする

【Swift】クロージャの理解

 引数としてのクロージャ、利点?

  • コードが綺麗になる。
  • 呼び出し側で、処理が記述できる

呼び出され側は、クロージャに引数として値を渡し、
「値は渡すから、その値はそちらで好きに料理してね」という具合です。

class Foo {
    let val:Int = 10

    func testClosure(closure: (Int) -> Void) {
        closure(self.val) // self
    }
}

class Bar {
    let foo = Foo()

    // 引数の値を倍にする
    func twice() {
        foo.testClosure{ arg in print(arg * 2) } // arg = argument = 引数
    }

    // 引数の値を半分にする
    func half() {
        foo.testClosure{ arg in print(arg / 2) }
    }
}

let bar = Bar()
bar.twice()    // 20
bar.half() // 5

 クロージャによる、 変数と定数のキャプチャ?

  • ローカルスコープ(= scope_1)で定義された変数や定数は、
    ローカルスコープ内でしか使用できませんが、(= scope_2では使用不可)

  • クロージャが参照している変数や定数は、
    クロージャが実行されるスコープ(= scope_4)が
    変数や定数が定義されたローカルスコープ以外(= scope_3以外)であっても、
    クロージャの実行時に使用できます。

これは、クロージャが 自身の定義されたスコープ(= scope_3) の
 変数や定数への参照を保持している為で、この機能をキャプチャと言います。

class Foo {
    // scope_2関数は、普通の関数scope_1を2回実行しています。
    func scope_1() {
        var toto = 1
        toto += 1 // toto = toto + 1 と同義 (= 変数totoの値を更新)
        print(toto)
    }

    func scope_2() {
        scope_1()
        scope_1()
    }

    // scope_4関数は、scope_3関数から返されたクロージャを2回実行しています。
    func scope_3() -> () -> Void {
        var tete = 10
        let closure = { tete += 1
            print(tete)
        }
        return closure
    }

    func scope_4() {
        let tutu = self.scope_3()
        tutu()
        tutu()
    }
}

let test = Foo()
test.scope_2() // 2, 2
test.scope_4() // 11, 12

// scope_2関数は、普通の関数scope_1を2回実行しています。

普通の関数は、実行後はリセットされるので、
1回目の実行で「2」と表示されても、実行後ローカル変数totoの値は「1」に戻ります。

なので2回目の実行でも「2」と表示されます。

// scope_4関数は、scope_3関数から返されたクロージャを2回実行しています。

クロージャが、scope_3関数のローカル変数teteを参照しているので、
1回目の実行で「11」、2回目の実行では1回目の参照を保持しているので「12」となります。


 キャプチャとは?

 自身の定義されたスコープの変数や定数への、参照を「保持」する機能。
 クロージャ特有の機能。

 クロージャにて、self を使う理由

 明示的なselfには、循環参照 が存在しないよう確認を促す役割がある。

(Escaping Closuresは)循環参照を起こす危険性があるので、
(プログラマにそれを意識させるために)クロージャーの中で親スコープを強参照するときは、selfをつける。

selfを付けることが、循環参照を防ぐことに直結しているわけではなくて
コードの意図が明確になり、循環参照しないかにプログラマが意識を向けて書くようになる。

 @escaping

image.png

 @escapingとは?

  • Swiftの 属性(= attribute)の1つ
  • @escaping属性は、クロージャに対して指定する追加情報
  • 関数に引数として渡されたクロージャが、関数のスコープ外で保持される可能性があることを示す

 この記事分かりやすいです?

var array = [()->Void]()

func testEscaping(arg: @escaping ()->Void) {
    //関数のスコープ外の配列に追加
    //クロージャが関数外で保持されることになるので、@escaping属性が必要
    array.append(arg)
}
testEscaping {print("玄邪 一郎")}
testEscaping {print("玄邪 二郎")}

array.forEach { $0() } //Swiftではクロージャの引数に自動的に$0、$1、$2…と順に名前が付与されます。

 属性(= attribute)とは?

  • コンパイラに対し、宣言や型の補足情報を伝えるもの
  • @を使う。

 @escapingを、 使う理由

 複数のタスクを、非同期で並列処理させるため。

 どんな時に、 必要か

  • クロージャが、スコープ外で強参照されるとき (= プロパティとして保持されるとき)
  • クロージャを、非同期で実行するとき (= メソッド内ですぐに実行されないとき)

 こちら参考

クロージャをすぐに実行し、どこからも強参照されない場合は、
@escaping は必要ありません。

 よくある例

 非同期処理をする、完了ハンドラとしてのクロージャ。

// NG: コンパイルエラー
func someAsyncMethod(completion: () -> Void) {
    DispatchQueue.main.async {
       completion()
    }
}

// OK
func someAsyncMethod(completion: @escaping () -> Void) {
    DispatchQueue.main.async {
       completion()
    }
}
// completionHandler -> クエリが完了したときに実行される
// DispatchQueue -> 処理待ちタスクを追加するためのキュー

 DispatchQueueについて

 「循環参照」 に 気を付けよう?

 @escapingなクロージャは、どこか から強参照される可能性があります。
 その参照元をクロージャ内で強参照すると、循環参照になります...

 循環参照

image.png

 循環参照とは?

 お互いにインスタンスを参照しあうため、
 どちらも解放されずにそのまま残り続けてしまう現象。? (ぐるぐる...)

image.png

 なぜNG?

 生成したインスタンスが メモリから解放されないと、メモリ リークとなるから。
 「解放」 = "ここ使い終わったから どうぞ"?

 メモリ リーク (= Memory leak)

永久にメモリを消費し続ける現象。
再現性が低く、テストもデバッグも極めて困難な悪質なバグとして有名。

class Sample {}

// Sample クラスのインスタンスが生成されるが参照カウンタが 0 のままなのですぐに解放される
Sample()

// Sample クラスのインスタンスが生成されて sample に代入されているので参照カウンタが 1 となり解放されない
var sample: Sample? = Sample()

// nil を代入すると参照がなくなるのでインスタンスの参照カウンタが 0 になり解放される
sample = nil

 参照カウンタ

 TODO: 後述

 循環参照 を 防ぐには?

 弱参照をする。
 weak または unowned という修飾子を使う。(unknownedではない...!)

un owned: 所有されていない
un known: 知られていない, 不明な
un known ed -> こんな言葉ない

 weakunownedの違い

 TODO: 後述

 なぜweakが、 循環参照を防げるのか?

 TODO: 後述

 [ ] とは何か

 TODO: 後述

普段なにげなく書いている[unowned self]の意味を調べる
参照カウントについて
これも

おしまい

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

iOS14で「バックグラウンド再生」が出来ない不具合への対処

概要

現在、リリースしているメディア管理系アプリで行ったiOS14に関する不具合調査とその対策です。

経緯とバグ調査

アプリを使って頂いているユーザーさんから「iOS14でバックグラウンド再生が出来ない」

との問い合わせがありました。

早速、手元の実機で調査してみると

動画ファイルを再生中 → アプリをバックグラウンドへ移行 → 再生が一時停止されてしまう

という現象を確認出来ました。

これが、iOS14の仕様(であればアプリ側のバグ)なのか

iOS14側の不具合なのかは今のところ分かりません。

ただ、音声ファイルの再生時にこの現象は起きないので
(バックグラウンドでも再生は継続される)

おそらく、iOS14の新しい仕様の可能性が高いのかなと思います。

ちなみに一時停止してしまった動画は、

コントロールセンターからプレイバックボタンを押す事で、再び再生が可能でした。

対策

バックグラウンドで動画再生させるケースはあまりない?と思われがちだが

実際、弊アプリのユーザーさんでは音楽動画などをBG再生して聴かれるケースも多いです。

症状は動画ファイル再生時のみですが、取り急ぎでも対応が必要です。

まずAVAudioSessionで色々やってみる

AVAudioSession

AVFoundation FrameworkAVAudioSessionのカテゴリーを調整してみます。

AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback, options: [])

ここのmodeoptionsを調整してみます。

extension AVAudioSession.Mode {
    public static let `default`: AVAudioSession.Mode
    public static let voiceChat: AVAudioSession.Mode
    public static let gameChat: AVAudioSession.Mode
    public static let videoRecording: AVAudioSession.Mode
    public static let measurement: AVAudioSession.Mode
    public static let moviePlayback: AVAudioSession.Mode
    public static let videoChat: AVAudioSession.Mode
    public static let spokenAudio: AVAudioSession.Mode
    public static let voicePrompt: AVAudioSession.Mode
}
    public struct CategoryOptions : OptionSet {
        public init(rawValue: UInt)
        public static var mixWithOthers: AVAudioSession.CategoryOptions { get }
        public static var duckOthers: AVAudioSession.CategoryOptions { get }
        public static var allowBluetooth: AVAudioSession.CategoryOptions { get }
        public static var defaultToSpeaker: AVAudioSession.CategoryOptions { get }
        public static var interruptSpokenAudioAndMixWithOthers: AVAudioSession.CategoryOptions { get }
        public static var allowBluetoothA2DP: AVAudioSession.CategoryOptions { get }
        public static var allowAirPlay: AVAudioSession.CategoryOptions { get }
    }

色々と組み合わせてみるが、効果無し。

仕方がないので、少し強引な対策で対処

根本的な対策方法については、引き続き調べていくとして

とりあえずの落としどころが欲しかったので、

  • まずAVPlayerItemのstatusを監視

  • システムから再生が停止されてしまった際 → playbackLikelyToKeepUpの変更を検知
    isPlaybackLikelyToKeepUp

  • そのタイミングでバックグラウンド再生中であれば、再び動画を再生させる。

と言う方法で対処することにしました。

override open func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?){

    if let item = object as? AVPlayerItem, let keyPath = keyPath {
        if item == self.playerItem {
            switch keyPath {
                        // ...略
            case #keyPath(AVPlayerItem.playbackLikelyToKeepUp):
                debugPrint("PlaybackLikelyToKeepUp is changed")             
                // ** BG状態かつ、再生ステータスなのにAVPlayerが停止という状態をチェック
                if isInBackground, isInconsistentPlaybackState {
                                    play()
                  debugPrint("Background Playback Forced Resuming >> Did Execute")
                }
            default:
                break
            }
        }
     }

}

調査は継続しつつ、これで様子を見ていきたいと思います。

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

iOS 14 UIDatePickerの以前と同じサイズ指定

iOS14 以降でUITextField の入力補助として UIDatePicker を使っている場合に
以前と同じサイズでピッカーを利用する例

    lazy var datePicker: UIDatePicker = {
        let datePicker: UIDatePicker = UIDatePicker()
        datePicker.datePickerMode = UIDatePicker.Mode.date
        datePicker.timeZone = NSTimeZone.local
        datePicker.locale = Locale.getPreferred()
        datePicker.date = Calendar.current.date(byAdding: .hour, value: 1, to: Date())!
        datePicker.minimumDate = Date()

        if #available(iOS 13.4, *) {
            datePicker.frame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: 250.0)
            datePicker.preferredDatePickerStyle = .wheels
        }
        return datePicker
    }()


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

NavigationControllerを使わずに横に遷移させる

コード

 func tappedNextButton() {
        let nextvc = NextViewController()
        nextvc.modalPresentationStyle = .fullScreen
        let transition = CATransition()
        transition.duration = 0.23
        transition.type = CATransitionType.push
        transition.subtype = CATransitionSubtype.fromRight
        view.window!.layer.add(transition, forKey: kCATransition)
        self.present(nextvc, animated: false, completion: nil)
    }

presentをanimated: false にするのを忘れないようにしてください。
Storyboardを使っている場合でも動きました。

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

iPadOS / macOSに対応しているか確認する

モチベーション

Apple SiliconによるiOSがiPadOS / macOSに統合の流れがあり、SwiftUIで広い画面を想定した実装を心がけた方が何かと役に立ちそう
iPad and iPhone apps on Apple Silicon Macs

チェック項目

  • iPadOS向けビルドするには何をすればいいのか?
  • フルSwiftUIに移行するにはどうすればいいのか?mainに何を書けばいいのか?
  • OS(Sizeに応じて)出し分けたい
    • readable width などのOS標準が無いか調べる
  • iPhoneで回転せず、iPadでのみ回転させたい

iPadOS向けビルドするには何をすればいいのか?

ビルド対象の追加

[Target] > [General] > [Deployment Info] の iPad にチェックがついていれば良い。
image.png

NavigationViewでフルスクリーン表示する

NavigationViewをデフォルトのまま利用すると、iPad上ではカラム表示されてしまいます。
NavigationViewを使うがiPadでも全画面で利用したい場合に下記の設定を入れる。

NavigationView {
   ... some view
}
.navigationViewStyle(StackNavigationViewStyle())

フルSwiftUIに移行するにはどうすればいいのか?mainに何を書けばいいのか?

App名と同じ.swiftファイルを作成する

メインとなるアプリ用のstructを用意し、bodyの中でWindowGroupを宣言する。

HogeApp.swift
@main
struct HogeApp: App {
    @StateObject private var model = HogeModel()
    @StateObject private var store = Store()

    var body: some Scene {
        WindowGroup {
            HogehogeContentView()
                .environmentObject(model)
                .environmentObject(store)
        }
    }
}

ScreenDelegateとAppDelegateを消す

ScreenDelegateとAppDelegateを消すだけで良いと言われたが、下記エラーが発生
Could not find a storyboard named 'Main' in bundle NSBundle

Info.plist内のApplication Session Role以下の内容をまるっと消すと動いた

Screen Shot 2020-08-19 at 17.13.29.png

OS(Sizeに応じて)出し分けたい

readable width などは無さそう、自前でmaxWidthを設定する必要がありそう

iPadの設定アプリなどのTableViewを見ると項目が画面幅最大まで広がらず、適度な大きさにとどまる様になっています。
この設定は readableContentGuide として主にTableViewなどで利用されています。

SwiftUIに上記の様な仕組みがあるか、確認したが見当たらなかった。
いずれは追加されるかもしれないが、一時的な解決策としてViewに対してmaxWidthを設定するようにしました。

幅の参考値としてiOSでの読みやすい幅を読んだところ、サイズに関わらず672が設定されるケースが多かったためその値を採用しました。

iPhoneで回転せず、iPadでのみ回転させたい

こちらは想定通りでは有りませんがWorkaround的に動いているので記載します。正しい解決方法など有りましたコメントいただけますとありがたいです。

やはり必要だったAppDelegateファイルを再度用意し、回転動作を縦画面(.portrait)に固定します。

AppDelegate.swift
class AppDelegate: NSObject, UIApplicationDelegate {

  private static var orientationLock = UIInterfaceOrientationMask.portrait

  func application(_ application: UIApplication,
                   supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
    AppDelegate.orientationLock
  }
}

次に、AppファイルにAppDelegateを結びつけます

HogeApp.swift
@main
struct HogeApp: App {
  @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
  /* ... */ 
}

現状(Xcode 12)はこの設定がiOSでしか有効にならず、iPadOSでは無視され回転してしまいます。
目的は達成されているので、一旦このままにします。

最後に

目に見える範囲だけ対応しましたので、他に懸念などお気づきの点があれば指摘、コメントお願い致します。

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

ScrollView のスクロール中だけ Lottie アニメーションを止めたい! (Swift)

はじめに

Lottie アニメーションを UIScrollView がスクロールしている最中のみ動かしたいときがあったので、その実装を紹介します。
具体的に言うと、

  • スクロール中: アニメーション停止
  • スクロール終了: アニメーション再開

という要求です。

準備: Lottie を動かす ViewController

ScrollView の動作関係なしに、ただ Lottie を動かす ViewController のコードを紹介します。
今回はこのコードを元に実装してみます。

コメント多めに書いてますので、ある程度は追えるかと思います。
今回はなんとなくランダムな位置に複数の Lottie アニメーションを配置してみました。

import UIKit
import Lottie

class ViewController: UIViewController {

    // MARK: - IBOutlet
    // scrollView の子要素で、 Lottie オブジェクトが配置される UIView
    @IBOutlet weak var contentView: UIView!

    // MARK: - Private properties
    var animations = [AnimationView]()

    // MARK: - Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        // ランダムな位置に 3 個の Lottie オブジェクトを生成・配置・再生する
        // 複数配置していることに特に意味はありません。
        for _ in 1...3 {
            let x = CGFloat.random(in: 1.0...5.0) * 200.0
            let y = CGFloat.random(in: 1.0...6.0) * 100.0
            let animation = generateAnimation(x: x, y: y)
            animations.append(animation)
            contentView.addSubview(animation)
            animation.play()
        }
    }

    // MARK: - Private methods
    // 引数で受け取った座標を持つ Lottie オブジェクトを生成するメソッド
    // see: https://qiita.com/ngo275/items/c9e94bad7a7afc85e4f4
    private func generateAnimation(x: CGFloat, y: CGFloat) -> AnimationView {
        let animationView = AnimationView(name: "sample_animation")
        animationView.frame = CGRect(x: x, y: y, width: view.bounds.width * 0.2, height: view.bounds.height * 0.2)
        animationView.loopMode = .loop
        animationView.contentMode = .scaleAspectFit
        animationView.animationSpeed = 1

        return animationView
    }
}

スクロール中だけアニメーションを止めてみよう

このままでは viewDidLoad ライフサイクルで play() されっぱなしで、止めようがありません。
今回の要求として、「スクロール中はアニメーションを止める」というのがありますので、まずは UIScrollView を Outlet 接続しましょう。

    // MARK: - IBOutlet
    // scrollView の子要素で、 Lottie オブジェクトが配置される UIView
    @IBOutlet weak var contentView: UIView!
    @IBOutlet weak var backgroundScrollView: UIScrollView! // 追加

次に、追加した backgroundScrollView の delegate 先を self つまり ViewController 自身に設定します。

    // MARK: - Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        backgroundScrollView.delegate = self // 追加

これで ScrollView の delegate メソッドが使えるようになりました。
UIScrollView の delegate メソッドは沢山ありますが、今回は

  • スクロールのし初め
  • スクロールの終わり

を検知することで

  • スクロールのし初め: アニメーションを一時停止
  • スクロールの終わり: アニメーションを再開

という風にして実装していきます。
沢山ある delegate メソッドの解説は 公式ドキュメント他の方がまとめてくださった記事 に譲って割愛します。
今回使うのは、以下の 2 つです。

    // 1.
    // 指が画面に触れ、スクロールが開始した瞬間に呼ばれるメソッド
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView)

    // 2.
    // 指が画面から離れ、慣性のスクロールが完全に止まった瞬間に呼ばれるメソッド
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView)

これらのメソッドを使えば、以下のようにして、簡単にスクロール中にアニメーションを止めて、スクロールが終わったらアニメーションを再開するということが可能になります。
ちなみに、 stop() メソッドを使用していますが、 pause() メソッドでも大丈夫です。
アニメーションが最後まで行って止まるか、 pause() された瞬間に止まるかの違いです。

    // 指が画面に触れ、スクロールが開始した瞬間に呼ばれるメソッド
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        print(#function) // どの関数が呼ばれているか確認用に表示
        // スクロール開始と同時にアニメーションをストップ
        animations.forEach { $0.stop() }
    }

    // 指が画面から離れ、慣性のスクロールが完全に止まった瞬間に呼ばれるメソッド
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        print(#function) // どの関数が呼ばれているか確認用に表示
        // スクロール終了と同時にアニメーションをスタート
        animations.forEach { $0.play() }
    }

あとは、これを実装して終わりです。
ViewController に UIScrollViewDelegate を継承させます。
僕は extension して書いていますが、 class ViewController: UIViewController, UIScrollViewDelegate としても何も問題ありません。

// MARK: - UIScrollViewDelegate
extension ViewController: UIScrollViewDelegate {
    // 指が画面に触れ、スクロールが開始した瞬間に呼ばれるメソッド
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        print(#function) // どの関数が呼ばれているか確認用に表示
        // スクロール開始と同時にアニメーションをストップ
        animations.forEach { $0.stop() }
    }

    // 指が画面から離れ、慣性のスクロールが完全に止まった瞬間に呼ばれるメソッド
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        print(#function) // どの関数が呼ばれているか確認用に表示
        // スクロール終了と同時にアニメーションをスタート
        animations.forEach { $0.play() }
    }
}

これでビルドしてみると、スクロールしている間だけアニメーションが止まることを確認できるはずです。
最後に、完成した ViewController を載せます。

import UIKit
import Lottie

class ViewController: UIViewController {

    // MARK: - IBOutlet
    // scrollView の子要素で、 Lottie オブジェクトが配置される UIView
    @IBOutlet weak var contentView: UIView!
    @IBOutlet weak var backgroundScrollView: UIScrollView!

    // MARK: - Private properties
    var animations = [AnimationView]()

    // MARK: - Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        backgroundScrollView.delegate = self

        // ランダムな位置に 3 個の Lottie オブジェクトを生成・配置・再生する
        // 複数配置していることに特に意味はありません。
        for _ in 1...3 {
            let x = CGFloat.random(in: 1.0...5.0) * 200.0
            let y = CGFloat.random(in: 1.0...6.0) * 100.0
            let animation = generateAnimation(x: x, y: y)
            animations.append(animation)
            contentView.addSubview(animation)
            animation.play()
        }
    }

    // MARK: - Private methods
    // 引数で受け取った座標を持つ Lottie オブジェクトを生成するメソッド
    // see: https://qiita.com/ngo275/items/c9e94bad7a7afc85e4f4
    private func generateAnimation(x: CGFloat, y: CGFloat) -> AnimationView {
        let animationView = AnimationView(name: "sample_animation")
        animationView.frame = CGRect(x: x, y: y, width: view.bounds.width * 0.2, height: view.bounds.height * 0.2)
        animationView.loopMode = .loop
        animationView.contentMode = .scaleAspectFit
        animationView.animationSpeed = 1

        return animationView
    }
}

// MARK: - UIScrollViewDelegate
extension ViewController: UIScrollViewDelegate {
    // 指が画面に触れ、スクロールが開始した瞬間に呼ばれるメソッド
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        print(#function) // どの関数が呼ばれているか確認用に表示
        // スクロール開始と同時にアニメーションをストップ
        animations.forEach { $0.stop() }
    }

    // 指が画面から離れ、慣性のスクロールが完全に止まった瞬間に呼ばれるメソッド
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        print(#function) // どの関数が呼ばれているか確認用に表示
        // スクロール終了と同時にアニメーションをスタート
        animations.forEach { $0.play() }
    }
}

参考文献

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