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

iOSアプリ開発:タイマーアプリ(5.アラーム、バイブレーションの実装)

記事内容

タイマーアプリを作成するためのポイントを複数記事に分けて掲載しています。
この記事では、タイマーのカウントダウンが0に到達したときに発動するアラームやバイブレーションの実装について掲載します。

開発環境

  • OS: macOS 10.15.x (Catalina)
  • エディタ: Xcode 12.x
  • 言語: Swift
  • 主な使用ライブラリ: SwiftUI

手順概要

  1. TimeManagerでAudioToolboxライブラリをインポートする
  2. TimeManagerにデフォルトのアラーム音名とアラームIDのプロパティを追加する
  3. MainViewでAudioToolboxライブラリをインポートする
  4. MainViewのZStackの.onReceiveモディファイアにアラームの発動を実装する
  5. MainViewのZStackの.onReceiveモディファイアにバイブレーションの発動を実装する

手順詳細

1. TimeManagerでAudioToolboxライブラリをインポートする

TimeManagerに新たにAudioToolboxというライブラリをインポートします。
AudioToolboxライブラリには、主に効果音的サウンドに関するクラス、プロパティ、メソッドが含まれますので、タイマーのカウントダウンが0に達したときにアラーム音を慣らすためにこれを利用します。

TimeManager.swift
import SwiftUI
import AudioToolbox //追加でインポート

class TimeManager: ObservableObject {
    //(プロパティ、メソッド省略)
}

2. TimeManagerにデフォルトのアラーム音名とアラームIDのプロパティを追加する

AudioToolboxに含まれる音源を利用するには、その音源のSystemSoundIDを指定する必要があります。SystemSoundIDはひとつのデータ型になっており、UInt32のtypealiasです。

ですので、TimeManagerクラスにこのデータ型のプロパティを作成し、@Publishedのプロパティラッパーをつけて、デフォルトで指定したい音源のIDを値として代入します。

どのIDがどんな音なのかは、以下のリソースが参考になります。
https://github.com/TUNER88/iOSSystemSoundsLibrary

このリソースの項目をひとつずつ確認してみましたが、iOSデバイスのかなり古い機種で利用されていた通知音のように思います。最近の機種でデフォルトで採用されている通知音の情報は見つかりませんでした。ご了承ください。(ご存知の方がいればコメントいただけると幸いです)

プロパティ名はsoundIDとし、ここでは、個人的に心地よい音源だと感じたので、1151をIDとして代入しました。

TimeManager.swift
import SwiftUI
import AudioToolbox //追加でインポート

class TimeManager: ObservableObject {
    //(他のプロパティ省略)

    //AudioToolboxに格納された音源を利用するためのデータ型でデフォルトのサウンドIDを格納
    @Published var soundID: SystemSoundID = 1151

    //(メソッド省略)
}

3. MainViewでAudioToolboxライブラリをインポートする

手順 1 のTimeManagerと同様に、MainViewのほうにも AudioToolbox ライブラリをインポートします。

こちらでAudioToolboxをインポートする理由は、実際にカウントダウンタイマーが0に達したタイミングでアラート音を鳴らすメソッドをMainViewに記述する必要があり、そのメソッドがAudioToolboxに含まれるからです。

MainView.swift
import SwiftUI
import AudioToolbox //追加インポート

struct MainView: View {
    //(プロパティ、メソッド省略)
}

4. MainViewのZStackの.onReceiveモディファイアにアラームの発動を実装する

MainViewのZStackの.onReceiveモディファイア内で、すでに残り時間が0より大きいか、0以下の場合でif-else文による条件分岐をさせていました。そのelse文(残り時間が0以下の場合)の中にアラーム音発動用のメソッドを追加します。

メソッドは以下のように記述します。第一引数に、手順2で先に用意したTimeManagerクラスのsoundIDプロパティを指定します。第二引数は nil で大丈夫です。

AudioServicesPlayAlertSoundWithCompletion(self.timeManager.soundID, nil)

MainView.swift
struct MainView: View {
    //(プロパティ省略)

    var body: some View {
        ZStack {
            //(省略)
        }
        //指定した時間(1秒)ごとに発動するtimerをトリガーにしてクロージャ内のコードを実行
        .onReceive(timeManager.timer) { _ in
            //タイマーステータスが.running以外の場合何も実行しない
            guard self.timeManager.timerStatus == .running else { return }
            //残り時間が0より大きい場合
            if self.timeManager.duration > 0 {
                //残り時間から -1 する
                self.timeManager.duration -= 1
            //残り時間が0以下の場合
            } else {
                //タイマーステータスを.stoppedに変更する
                self.timeManager.timerStatus = .stopped
                //アラーム音を鳴らす
                AudioServicesPlayAlertSoundWithCompletion(self.timeManager.soundID, nil)
            }
        }
    }
}

5. MainViewのZStackの.onReceiveモディファイアにバイブレーションの発動を実装する

アラーム音の次はバイブレーションの実装です。これもメソッドはアラーム音発動と同じものを使います。引数が変わります。

引数には kSystemSoundID_Vibrate プロパティによって得られたバイブレーション用のsoundIDの値をSystemSoundIDデータ型に変換して渡しています。

AudioServicesPlayAlertSoundWithCompletion(SystemSoundID(kSystemSoundID_Vibrate)) {}

MainView.swift
struct MainView: View {
    //(プロパティ省略)

    var body: some View {
        ZStack {
            //(省略)
        }
        .onReceive(timeManager.timer) { _ in
            guard self.timeManager.timerStatus == .running else { return }
            if self.timeManager.duration > 0 {
            //(省略)
            } else {
                //(省略)
                //アラーム音を鳴らす
                AudioServicesPlayAlertSoundWithCompletion(self.timeManager.soundID, nil)
                //バイブレーションを作動させる
                AudioServicesPlayAlertSoundWithCompletion(SystemSoundID(kSystemSoundID_Vibrate)) {}
            }
        }
    }
}

次回は、アラームとバイブレーションのオン/オフなどを含む設定画面を作成していきます。

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

これだけ覚えておけばなんとかなる コードで書くAutoLayout

自社開発ベンチャー企業でインターン中のkyoyaです。
今回実務でコードでのAutoLayout設定を行ったのでそのアウトプットをしておきます。

AutoLayoutとは何か??

CSSを触ったことがある人ならわかると思いますが、iphone版レスポンシブデザインと思っておけば大丈夫だと思います。現在発売されているiphoneは15種類以上にものぼり、それだけ画面の数も多様性を増しています。
なのでレスポンシブなデザインを意識しておかないと、iphone11では動くのにiphone8だとデザインが崩れる!というふうになりかねません、、、
そこでレスポンシブなデザインのために使われるのがAutoLayoutという技術なのです。

IBでのAutoLayoutとコードでのAutoLayout

IB(InterfaceBuilder storyboardのことです)とコードどちらでもAutoLayoutが組めますし、どちらも一長一短です。IBであれば直感的に、視覚的に初心者でもわかりやすくレイアウトを組めますし、コードだと複数人開発の際にレビューしやすいなどのメリットがあります。今回はコードでのAutopLayoutを説明していこうと思います。

○○Anchorについて

コードでレイアウトを組む時によく出てくるのがこの○○Anchorというものです。Anchorというのは日本語で「船のいかり」などというふうによく訳されます。いかりは船を固定する時に使うものなのでレイアウトを固定するもの(プロパティ)というふうに思ってもらえれば大丈夫です。
以下のように使います。

testButton.topAnchor.constraint(equalTo:view.bottomAnchor)

(constraint~については後で説明します。)
ここで理解して欲しいのはtestButtonの上辺をButtonの上辺をviewの下辺に固定する(くっつける)ということです。つまりtestButtonの上辺(top)を固定している(anchor)...top + anchor = topAnchor!!!
このほかにもleftAnchor、rightAnchor、bottomAnchor、heightAnchor、widthAnchorなどがあります。
left,right,bottomについては、topAnchorと同じように上下左右の位置を決めるものですが、heightやwidthについては高さや幅についての制約なので注意してください

constraintについて

後で説明すると言ったconstraintについて説明します。
constraintは日本語で固定という意味があります。
つまりAnchorを固定する(constraintする)という意味になります。
constraintにも種類があって、どこにくっつけるかを表すconstraint(equalTo:NSLayoutAnchor)、どことどれだけ離すかを表すconstraint(equalTo:NSLayoutAnchor,constant:Int)、どことどれ以上離すかを表すconstarint(greaterThanOrEqualTo:NSLayoutAnchor)などがあがりますが今言ったものを覚えておけばいいでしょう。
上記の例に戻ってみます

tesuButton.topAnchor.constraint(equalTo:view.bottomAnchor)

これはtopAnchorをviewのbottomAnchorとくっつけてくださいということです。
こういうふうにもかけます

testButton.topAnchor.constraint(equalTo:view.bottomAnchor,constant:0)

これはtopAnchorをviewのbottomAnchorから0pxの距離においてくださいということです。
例えばボタンとボタンの幅を20pxにしたい時は以下のような書き方があります。

let button1 = UIButton()
let button2 = UIButton()

button1.rightAnchor.constraint(equalTo: button2.leftAnchor, constant: 20)
//以下でも大丈夫です。
// button2.leftAnchor.constraint(equalTo: button1.rightAnchor, constant: 20)

ボタン1が左、ボタン2が右にあるとしたらボタン1の右側の位置をボタン2の左側の位置と20px離すようにすればいいですね。

HeightAnchor、WidthAnchorの制約の付け方について

これらの制約の付け方はHeightAnchor.constraint(equalToConstant:)と引数名が少し違うので気をつけてください

実際に制約をつける時

上記のようにAnchor.constarintとすると制約が付けれます。しかしこのコードは書いただけではプログラムに制約があると知らせるだけでビューには反映してくれません。書いた制約を有効化する必要があるのです。

let constraints = [
     button1.rightAnchor.constraint(equalTo: button2.leftAnchor, constant: 20),
     button1.topAnchor.constraint(equalTo: testLabel.bottomAnchor, constant: 30),
]
NSLayoutConstraint.activate(constarints)

このように制約の入った配列を引数に渡すことで制約をビューに反映することができます。この処理はviewDidLoad内でやるといいでしょう。

もう一つだけ注意すること

AutoLayoutの処理をする前にこちらのプロパティを設定しておくのを忘れないようにしましょう

let object = Object("何かしら制約をつけたいもの")
object.translatesAutoresizingMaskIntoConstraints = false

このtranslatesAutoresizingMaskIntoConstraintsというプロパティは各ビューに用意されているものですが、Auto Layout以前に使われていた、Autosizingのレイアウトの仕組みをAuto Layoutに変換するかどうかを設定する値です。
これは初期値がtrueなので手動でfalseにしておかないと期待通りのレイアウトにならないことがあります。

ちょっと技術的な解説

○○Anchorがconstraintを持っててどうのこうのって聞けばなんとなくわかりますけど、自分で使うとなると少し抵抗がありますよね。(プログラミングで例え話は確かにわかりやすいが使用時にはきちんと技術的に理解しておいた方がいい)なので少し技術的な話をしていこうと思います
Appleから提供されているUIKitフレームワーク(ボタンとかビューなどがまとまっているもの)には基本的にNSLayoutAnchorクラスのプロパティが備わっています。そしてそのNSLayoutAnchorクラスはconstarint(equal~~)というメソッドが用意されています。
なので○○Anchorと宣言するとconstraintメソッドが使えるというわけですね。
これを理解しておけば今後使う時に自信を持って使えると思いますのでご参考までに

今回の記事は以上です。IBを使ったAutoLayoutやレイアウトのタイミングについてもまた投稿したいと思います。
ありがとうございました。

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

さっそくiOS14のWidgetKitを導入してみて分かったこと

iOS 14 からウィジェットが導入されて、ホーム画面から様々な情報を得ることができるようになりましたね。今回は、自分のアプリで導入にチャレンジしてみて得た Tips を軽くまとめたいと思います。

Tips(ポジティブ編 ?)

ウィジェットのサイズは3タイプあり、指定できる

ウィジェットには、Small、Medium、Large の3タイプがありますが、WidgetConfiguration の supportedFamilies()で任意のタイプのみを指定できます。また、表示する時にそれぞれのタイプごとにコンテンツを切り替えることもできます。

Web 通信した情報に基づいて表示を更新できる

URLSession を用いた通信であれば許されます。https を用いましょう。通信が重い非同期処理の場合はonBackgroundURLSessionEvents()というのを使えばいい感じになるらしいですが、挑戦例がほぼないので使い方はよくわかりませんでした。

Asset Catalog を使っていくつかの項目の色を指定できる

WidgetKit のターゲットに Asset Catalog を追加すると、AccentColor と WidgetBackground の二つの項目がデフォルトで用意されます。AccentColor はウィジェットを追加する時のボタンの色を指定できます。WidgetBackground は指定しただけだと背景色に変化は起こりませんが、SwiftUI の Color で指定すれば使えます。

Tips(ネガティブ編 ?)

UIKit のコンポーネントを含んだビューは扱えない

SwiftUI の View はUIViewRepresentableを使えば UIKit の View コンポーネントを組み込むことができますが、WidgetKit は UIViewRepresentable 非対応です。(ソース

リンクを開く以外のユーザアクションは皆無

ボタンを押して本体アプリのあるページに飛ぶというのはできますが、ウィジェット上でユーザインタラクションをすることはできません。面白くないですね。HIG 的にはただ一瞬情報を確認させるためだけのものというのがウィジェットの立ち位置のようです。

アニメーションはできない

あらゆる手段を使ってみましたが、アニメーションを含んだビューは正しく表示されません。唯一アニメーションを許された時計アプリが羨ましい...

ウィジェット背景の自由度は低め

  • 透明/半透明にはできない
  • UIVisualEffectViewみたいな磨りガラス風にはできない
  • ライトモード/ダークモードで背景色を変更することは可能

所感

ウィジェット系は昔から悪戯し甲斐があるので好きなのですが、こと WidgetKit に関しては悪戯できる余地がほとんどなくて非常につまらないです。サードパーティのウィジェットが活発に流行る気がしません。

宣伝

GitHub のコントリビューション(草)の状態を確認することができるアプリ GitGrass をウィジェットに対応させてみました。タダなので興味のある方はインストールしてみてください。AppStore リンクリポジトリのリンク
kusa.png

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

iOS14のウィジェットのテンプレートをIntentConfigurationで作る。

iOS14で追加になったウィジェットのテンプレートをIntentConfigurationで作ります。
("ウィジェットを編集"メニューが表示される方)
できるだけXcodeの自動生成を使います。
テンプレートなので何の機能もありませんが、ウィジェットを量産するときの基礎になることを期待しています。

Appの作成

まだUIKitで作っているアプリがほとんどだと思うので、UIKit App Delegate を選択しています。
名前はクレジットカードアプリを想定してLeaderCardにしました。:relaxed:
image.png

Widget Extensionの作成

ここの+を押す。
image.png

Widget Extensionを選択してNextを押す。
image.png

Include Configuration Intentにチェックを入れる。(←ここ大事!)
チェックを入れないとStaticConfigurationになります。
Widget名はLeaderCardWidgetにしました。
image.png

Intent Handlerの作成

ここの+を押す。
image.png

Intents Extensionを選択してNextを押す。
image.png

Include UI Extensionをチェックしない。(←ここ大事!)
Starting PointNoneを選択。(←ここ大事!)
Intent Handler名はLeaderCardIntentにしました。
image.png

intentdefinitionの設定

LeaderCardWidgetフォルダーの中に生成されているLeaderCardWidget.intentdefinisionを開く。
image.png
下方にある+を押してNew Typeを選択。
image.png
タイプ名にCardを設定する。Cardの中身は触らなくても良いです。
image.png

Parameters

下図のように設定する。
image.png

Parameter

Parameterは小文字でcardと設定する。(←ここ大事!)
image.png
自動生成されるクラスConfigurationIntentのプロパティ名になっているからです。

Display Name

ここで指定した文字列は下図の場所で表示されます。

Type

Cardを選択する。

Configurable

チェックする。

Dynamic Options

チェックする。

Prompt Label

ここで指定した文字列は下図の場所で表示されます。

Target Membership

LeaderCardWidgetExtensionLeaderCardIntentにチェックする。(←ここ大事!)
image.png

Intent Handlerを最低限コーディングする

初期状態

IntentHandler.swift
class IntentHandler: INExtension {

    override func handler(for intent: INIntent) -> Any {
        // This is the default implementation.  If you want different objects to handle different intents,
        // you can override this and return the handler you want for that particular intent.

        return self
    }

}

ConfigurationIntentHandlingプロトコルを追加する。
image.png
そうすると、protocol stubを追加するか聞かれるのでFixを押す。
image.png
provideCardOptionsCollectionメソッドが追加される。
image.png

codeの部分にcompletion(nil, nil)を書く。
(テンプレートなので何も機能しないです。)

IntentHandler.swift
class IntentHandler: INExtension {
    func provideCardOptionsCollection(for intent: ConfigurationIntent, with completion: @escaping (INObjectCollection<Card>?, Error?) -> Void) {
        completion(nil, nil)
    }

    override func handler(for intent: INIntent) -> Any {
        // This is the default implementation.  If you want different objects to handle different intents,
        // you can override this and return the handler you want for that particular intent.

        return self
    }

}

完成

これでテンプレートが完成したはずです。
実行すると以下のような画面が表示されます。
これに肉付けして目的のウィジェットに近づけていくことになります。
image.png


GitHub

こちらにTemplate repositoryを作っておりますので、よろしければご利用ください。
iOS14-Widget-IntentConfiguration-template

開発環境

  • Xcode 12.1GM
  • iOS 14.0 - 14.1
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOSアプリ開発:タイマーアプリ(4.カウントダウンの実装)

記事内容

タイマーアプリを作成するためのポイントを複数記事に分けて掲載しています。
この記事では、カウントダウンタイマーで実際に残り時間の表示を1秒毎に更新する手順について掲載します。

開発環境

  • OS: macOS 10.15.x (Catalina)
  • エディタ: Xcode 12.x
  • 言語: Swift
  • 主な使用ライブラリ: SwiftUI

手順概要

  1. TimeManagerクラスにTimerクラスpublishメソッドを追加する
  2. MainViewの残り時間の表示を1秒毎に更新する

手順詳細

1. TimeManagerクラスにTimerクラスpublishメソッドを追加する

現時点では、スタートボタンを押しても、タイマーステータスは.runningに変わりますが、画面上はまだ残り時間がカウントダウンされません。画面上はPickerで設定した時間のまま止まっています。

そこで、このタイマーアプリの肝となる残り時間のカウントダウン表示を実装していきます。

SwiftUIライブラリにも含まれますが、Swift言語の一番ベースとなるFoundationライブラリに用意されているTimerというクラスのpublishというメソッドを利用します。

以下のように記述することで、publishメソッドの引数everyで指定した時間(1秒)ごとに発動するタイマーを変数timerに格納します。これをView側で利用することで、何らかのアクションを1秒毎に発動したいときにトリガーになってくれます。ここでは、タイマーアプリが残り時間を1秒毎に更新する際にトリガーとして利用していきます。

TimeManager.swift
class TimeManager: ObservableObject {
    //(他のプロパティ省略)

    //1秒ごとに発動するTimerクラスのpublishメソッド
    var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    //(メソッド省略)
}

2. MainViewの残り時間の表示を1秒毎に更新する

MainViewでTimerViewを含んでいる一番外側のZStackに .onReceiveモディファイアを追加します。ZStack内のすべてのViewに反映されます。

この.onReceiveモディファイアがTimeManagerクラスのtimerの1秒毎の発動を受けとります。そして、そのあとのクロージャにはtimer発動ごとに実行するコードを記述します。

guard let ~ else 構文で、タイマーステータスが.running以外の時は何も実行しないようにクロージャにはreturnを記述します。
タイマーステータスが.runningであれば、それ以下のif文へ進みます。

if文では、残り時間が0より大きい時は、残り時間から1秒引き算し、残り時間が0以下の場合は、タイマーステータスを.stoppedに変更するように記述します。

MainView.swift
struct MainView: View {
    @EnvironmentObject var timeManager: TimeManager

    var body: some View {
        ZStack {
            if timeManager.timerStatus == .stopped {
                PickerView()
            } else {
                TimerView()
            }

            VStack {
                Spacer()
                ButtonsView()
                    .padding(.bottom)
            }
        }
        //指定した時間(1秒)ごとに発動するtimerをトリガーにしてクロージャ内のコードを実行
        .onReceive(timeManager.timer) { _ in
            //タイマーステータスが.running以外の場合何も実行しない
            guard self.timeManager.timerStatus == .running else { return }
            //残り時間が0より大きい場合
            if self.timeManager.duration > 0 {
                //残り時間から -1 する
                self.timeManager.duration -= 1
                //残り時間が0以下の場合
            } else {
                //タイマーステータスを.stoppedに変更する
                self.timeManager.timerStatus = .stopped
            }
        }
    }
}

これでようやくカウントダウンタイマーらしくなってきました。
次回は、アラームの実装をしていきます。

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

iOS14(xcode12ビルド)で、WKWebView の contentInset.top を一定値以上セットすると webView の load 中になぜか最下部までスクロールされる バグ?

iOS14+xcode12によるビルドでバグのような挙動が発生しているので報告です

WKWebView の contentInset.top を一定値以上セットすると、
webView の load 中になぜか最下部にスクロールされる

というものになります。

一定値というのは700〜800あたりで発生することを確認してます
500とかだと発生しません

以下再現コードです
WKWebViewを全面に配置しただけのstoryboardもあらかじめ作ってあります

class ViewController: UIViewController {
    @IBOutlet weak var webView: WKWebView!

    override func viewDidLoad() {
        super.viewDidLoad()

        let url = URL(string: "https://cookpad.com")!

        let request = URLRequest(url: url)

        webView.load(request)

        webView.scrollView.contentInset.top = 800.0

        DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
            self.webView.reload()
        }
    }
}

実行すると、なぜか最下部までスクロールした状態でロードされます。
リロード後も同じ。

例:

しかし、軽いページなのかなにか条件があるのか、一度目はちゃんと最上部のままロードされるページもあります
(google.co.jp等)
ただ、その場合も reload() 後は最下部にスクロールされた状態になります

iOS14SDKのバグっぽい挙動なのですが、報告です

もし回避策がありましたら教えていただけるとありがたいです!

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

iOSアプリ開発:タイマーアプリ(3.スタート/ストップボタン、リセットボタン)

記事内容

タイマーアプリを作成するためのポイントを複数記事に分けて掲載しています。
この記事では、カウントダウンタイマーのメイン操作となるスタート、一時停止、リセット機能を実装するための手順を掲載します。

開発環境

  • OS: macOS 10.15.x (Catalina)
  • エディタ: Xcode 12.x
  • 言語: Swift
  • 主な使用ライブラリ: SwiftUI

手順概要

  1. TimeManagerにタイマーのステータスを示すプロパティを作成する
  2. TimeManagerにタイマーのステータスを変更するメソッドを作成する
  3. ButtonsViewを作成する
  4. ButtonsViewにスタート/ストップボタンを作成する
  5. ButtonsViewにリセットボタンを作成する
  6. MainViewにButtonsViewを配置する

手順詳細

1. TimeManagerにタイマーのステータスを示すプロパティを作成する

タイマーのステータスを表すプロパティをTimeManagerクラスに作成しておきます。ステータスは以下の3つとします。

  • running:タイマーがカウントダウン中の状態
  • pause:タイマーが一時停止中の状態、再開可能
  • stopped:タイマーがカウントダウン終了している状態

まず下ごしらえとして、Data.swiftファイルにタイマーのステータスを表す新しいenumを作成します。

Data.swift
//(他のenum省略)

enum TimerStatus {
    case running
    case pause
    case stopped
}

作成したenumをデータ型としてTimeManagerクラスにプロパティを作成します。タイマーは使用者がスタートボタンをタップするまでは作動していない状態にする必要があるので、このプロパティのデフォルトの値は.stoppedにしておきます。

TimeManager.swift
class TimeManager: ObservableObject {
    //(他のプロパティ省略)

    //タイマーのステータス
    @Published var timerStatus: TimerStatus = .stopped

    //(メソッド省略)

2. TimeManagerにタイマーのステータスを変更するメソッドを作成する

まだボタンを作成していませんが、先にそれぞれのボタンをタップしたときに、タイマーをスタートしたり、一時停止したり、完全に終了させるメソッドをそれぞれ作成します。言い換えると、これらのメソッドにより先に作成したタイマーステータスのプロパティの値が都度変更される形になります。

TimeManager.swift
class TimeManager: ObservableObject {
    //(プロパティ省略)

    //(他のメソッド省略)

    //スタートボタンをタップしたときに発動するメソッド
    func start() {
        //タイマーステータスを.runningにする
        timerStatus = .running
    }

    //一時停止ボタンをタップしたときに発動するメソッド
    func pause() {
        //タイマーステータスを.pauseにする
        timerStatus = .pause
    }

    //リセットボタンをタップしたときに発動するメソッド
    func reset() {
        //タイマーステータスを.stoppedにする
        timerStatus = .stopped
        //残り時間がまだ0でなくても強制的に0にする
        duration = 0
    }
}

3. ButtonsViewを作成する

ButtonsViewという名前のswiftファイルを新たに作成します。同名のstructが生成されます。

ボタン操作と手順1で作成したTimeManagerクラスのtimerStatusプロパティを連携する必要があるため、このViewでもTimeManagerクラスのインスタンスを作成しておきます。例によって、@EnvironmentObjectのプロパティラッパーもつけておきます。

ButtonsView.swift
import SwiftUI
    @EnvironmentObject var timeManager: TimeManager

struct ButtonsView: View {
    var body: some View {

    }
}

4. ButtonsViewにスタート/ストップボタンを作成する

スタートボタンとストップボタンは画面上同じ場所に表示することにします。
タイマーステータスによって、どちらのボタンを画面上に表示するかを条件分岐させます。

  • .runningの時は一時停止ボタンを表示
  • .pause または .stoppedの時はスタートボタンを表示

ボタンアイコンには、Apple純正SF Symbolsから以下2つを採用します(オーディオの再生、一時停止によく使われるアイコンです)。

  • play.circle.fill:スタートボタン
  • pause.circle.fill:一時停止ボタン

残り時間の表示が0のときは、スタートも一時停止もできないことを示すため、透明度の.opacityモディファイアで条件分岐を作っておきます。

ボタンをタップしたときのアクションとして、PickerViewが表示(そして時間設定)されている場合、Startボタンをタップするとタイマーがセットされるように、setTimerメソッドを指定します。PickerViewが表示されている時というのは、タイマーステータスが.stoppedの時なので、これをonTapGestureモディファイアの中にif文で記述します。

ボタンをタップしてこのsetTimerメソッドが実行されると、残り時間のdurationプロパティ、最大時間のmaxValueプロパティに設定した時間が代入されます。このdurationが0以外の状態、かつタイマーステータスが.runningではない状態の時startメソッドも実行されるようにonTapGestureモディファイアの中にif文で記述します。

別の条件としてタイマーステータスが.runningの時のみ一時停止できるように、先のif文に今度はif else文で続けて記述していきます。

ButtonsView.swift
struct ButtonsView: View {
    @EnvironmentObject var timeManager: TimeManager

    var body: some View {
        //running: 一時停止ボタン/pause or stopped: スタートボタン
        Image(systemName: self.timeManager.timerStatus == .running ? "pause.circle.fill" : "play.circle.fill")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(width: 75, height: 75)
            //ボタンの右側とスクリーンの端にスペースをとる
            .padding(.trailing)
            //Pickerの時間、分、秒がいずれも0だったらボタンの透明度を0.1に、そうでなければ1(不透明)に
            .opacity(self.timeManager.hourSelection == 0 && self.timeManager.minSelection == 0 && self.timeManager.secSelection == 0 ? 0.1 : 1)
            //ボタンをタップした時のアクション
            .onTapGesture {
                if timeManager.timerStatus == .stopped {
                    self.timeManager.setTimer()
                }
                //残り時間が0以外かつタイマーステータスが.running以外の場合
                if timeManager.duration != 0 && timeManager.timerStatus != .running {
                    self.timeManager.start()
                //タイマーステータスが.runningの場合
                } else if timeManager.timerStatus == .running {
                    self.timeManager.pause()
                }
            }
    }
}

5. ButtonsViewにリセットボタンを作成する

ButtonsViewの中に、さらにリセットボタンを作成します。

このボタンをタップすると、手順2で作成したTimeManagerクラスのresetメソッドが発動します。

ボタンのアイコンには、SF Symbolsの"stop.circle.fill"を採用しました。

タイマーステータスが.stopped以外の場合にresetメソッドが発動するように、onTapGestureモディファイアの中にif文で記述します。

ボタンのレイアウトについて、リセットボタンを画面の左、先に作成したスタート/一時停止ボタンを画面の右に配置したいので、HStackの中に両方のボタンを配置します。

デフォルトのままだと、どちらのボタンも配置が画面中央になるため、ボタンとボタンの間にSpacerを入れ、ボタンがスクリーン両端にそれぞれ寄るようにします。

ボタンをスクリーン端に寄せすぎると見栄えが悪いので、paddingで調整しています。

ButtonsView.swift
struct ButtonsView: View {
    @EnvironmentObject var timeManager: TimeManager

    var body: some View {
        //HStackで画面の左にリセットボタン、右にスタート/一時停止ボタン
        HStack {
            //リセットボタン
            Image(systemName: "stop.circle.fill")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 75, height: 75)
                //ボタンの左側とスクリーンの端にスペースをとる
                .padding(.leading)
                //タイマーステータスが終了なら透明度を0.1に、そうでなければ不透明に
                .opacity(self.timeManager.timerStatus == .stopped ? 0.1 : 1)
                //ボタンをタップしたときのアクション
                .onTapGesture {
                    //タイマーステータスが.stopped以外の場合
                    if timeManager.timerStatus != .stopped {
                    self.timeManager.reset()
                }

            //ボタンとボタンの間隔をあける
            Spacer()

            //running: 一時停止ボタン/pause or stopped: スタートボタン
            Image(systemName: self.timeManager.timerStatus == .running ? "pause.circle.fill" : "play.circle.fill")
                //(モディファイア省略)
        } 
    }
}

6. MainViewにButtonsViewを配置する

MainViewにはすでにPickerViewとTimerViewが追加してありました。

タイマーステータスが.stoppedかどうかで、どちらのViewが表示されるか変わるようにif-else文で記述しますします。

PickerViewとTimerViewの2つは、このタイマーアプリのもっとも重要なコンポーネントのため、画面中央に配置しておきます(配置を指定しなければデフォルトで水平、垂直方向で中央になります)。

iPhoneなどiOSデバイスの指での操作を考えると、今回追加したい ButtonsView は PickerView / TimerView より下、それもスクリーン下端に寄せる形で配置したいので、ZStack で PickerView / TimerView とはレイヤーを分ける形にし、VStack で ButtonsView の上に Spacer を配置することでButtonsViewを下端へ寄せます。ただしスクリーン端ギリギリだと見栄えが良くないため、padding(.bottom)モディファイアで微調整しておきます。

MainView.swift
struct MainView: View {
    @EnvironmentObject var timeManager: TimeManager

     var body: some View {
        ZStack {
            if timeManager.timerStatus == .stopped {
                PickerView()
            } else {
                TimerView()
            }

            VStack {
                Spacer()
                ButtonsView()
                    .padding(.bottom)
            }
        }

    }
}

次回は残り時間のカウントダウン表示を実装していきます。

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

Swift Optionalの研究

Optional型

Optional型に関していろいろ書籍等でもう一度勉強し直したのでついでに書きます。
自分で勉強したものをそのまま載せますので、見辛いかもしれませんがご了承ください
使い方というより、そもそも的な物なので、使い方的なものに関しては、他の記事を読んだ方が良いです。

ざっくり Optionalとは

  • 値があるか空かいずれかを表す型
  • 基本的にはnilは許容しないが許容する場合はOptionalを使う
  • Wrappedはプレースホルダー型という
  • Wrappedを具体的な型に置き換えて使用
// Optionalはenumで定義されている
enum Optional<Wrapped> {
    case uone
    case some(Wrapped)
}

let none = Optional<Int>.none
print(".none: \(String(describing: none))")
let some = Optional<Int>.some(1)
print(".some: \(String(describing: some))")

* 型推論
let some2 = Optional.some(1) // Optional<Int>
let none2: Int? = Optional.none // Optional<Int> nil
// .someは型推論できる .noneは型指定しないとダメ Error

var a: Int?
a = nil         // nilリテラル代入による.noneの生成
a = Optional(1) // イニシャライザによる.someの生成
a = 1           // 値代入による.someの生成
let opInt: Int? = nil
let opString: String? = nil

print(type(of: opInt), String(describing: opInt))
print(type(of: opString), String(describing: opString))
// Optional<Int> nil
// Optional<String> nil

*イニシャライザによる.someの生成*

let opInt2 = Optional(1)
let opString2 = Optional("a")
print(type(of: opInt2), String(describing: opInt2))
print(type(of: opString2), String(describing: opString2))
// Optional<Int> Optional(1)
// Optional<String> Optional("a")

*値代入による.someの生成*

let opInt3: Int? = 1
print(type(of: opInt3), String(describing: opInt3))
// Optional<Int> Optional(1)
// アンラップ
// Optional<Wrapped>型は値を持っていない可能性があるため
// Wrapped型の変数や定数と同じように扱うことができない
// Int?型どうしの演算はerrorになる
let aa: Int? = 1
let bb: Int? = 1
// aa + bb これだとerror
/*
 Optional<Wrapped>型の値が持つWrapped型の値に対する操作を行うには、
 Optional<Wrapped>型の値からWrapped型に取り出す必要がある
 Wrapped型の値を取り出す操作をアンラップと言う
 */

* 大事なところ

// オプショナルバインディング
// ??演算子
// 強制アンラップ

* オプショナルバインディング
/*
 条件分岐や繰り返し文の条件にOptional<Wrapped>型の値をしていする
 値の存在が保証させている分岐内では、Wrapped型の値に直接アクセスすることができる
 if-let文
 if let 定数名 Optional<Wrapped>型の値 {
  値が存在する場合に実行される文
 }
 */

let optionalA = Optional("a") // String?型
if let a = optionalA {
    print(type(of: a)) // optionalAに値があるときのみ実行される
}
// String

* ??演算子
// 値が存在しない場合 defaultの値を表示
let optionalInt: Int? = 1
// let optionalInt: Int? = nil この場合 3が表示させる
let int = optionalInt ?? 3 // 1

* 強制アンラップ
/*
 Optional<Wrapped>型からWrapped型の値を強制的に取り出す方法
 強制的というのは、値が存在しない場合実行Errorになることを意味する
 !演算子を使用
 */

let num1: Int? = 1
let num2: Int? = 1
// 強制アンラップのやりかた
num1! + num2! // 2

/*
 強制アンラップは値がないケースを無視しているので、errorの危険性がある
 多用は避ける
 値の存在がよほど明らかな場合や、
 値が存在しない時はプログラムを終了させたい箇所以外は基本的に使用を避ける
 */

* オプショナルチェイン
// Optional<Double>型からDouble型の isInfiniteプロパティにアクセスするために
// オプショナルバインディングをしている

let optionalDouble = Optional(1.0)
let optionalIsInfinite: Bool?

if let double = optionalDouble {
    optionalIsInfinite = double.isInfinite
} else {
    optionalIsInfinite = nil
}
print(String(describing: optionalIsInfinite))

/*
 オプショナルチェインを使えばアンラップをしないでもWrapped型のプロパティやメソッドにアクセスできます。
 オプショナルチェインを利用する場合はOptional<Wrapped>の四季の後に?に続けて
 Wrapped型のプロパティやメソッド名を記述する
 Optional<Wrapped>の型の変数や定数がnilだった場合?以降に記述されたプロパティやメソッドへのアクセスは行わずに nilが返却される
 元のOptional<Wrapped>型の式が値を持ってないということは
 アクセス対象のプロパティやメソッドも存在しないということであり返すべき値も存在しないためです
 下記例では上のCodeをオプショナルチェインを使って書き換えたもの 結果はBool?型
 */

let opDouble = Optional(1.0)
let opIsInfinite = opDouble?.isInfinite
print(String(describing: opIsInfinite))

// 下記例はcontainsを呼び出し
// CountableRange<Int>?型の定数optionalRangeの範囲に指定した値が含まれているかどうかを
// 判定しています 結果はBool?値

let optionalRange = Optional(0..<10)
let containsSeven = optionalRange?.contains(7)
print(String(describing: containsSeven))

* map flatMap

// アンラップしないで値変換するメソッド
// Int?型の定数num3に対して値を2倍にするクロージャーを実行して、
// 結果としてInt?型の値Optinal(34)を受け取っている

let num3 = Optional(17)
let num4 = num3.map { value in
    value * 2
} // 34
type(of: num4) // Optional<Int>.Type
/*
また map を使って別の型に変換できる
 Int?型 num5に対してIntをStringに変換するクロージャーを実行して、結果としてString?の17を受け取る
 */

let num5 = Optional(17)
let num6 = num5.map { val in
    String(val)
} // "17"
type(of: num6)

// flat(Map
// クロージャーの戻り値はOptionalになる
let num7 = Optional("17")
let num8 = num7.flatMap { val in
    Int(val)
}
type(of: num8) // Optional<Int>.Type
// ポイント
/*
 値の有無が不確かな定数に対し、さらに値を返すか定かではない操作を行なっている点です
 ここがflatMapではなくMapの場合
 最終結果は二重にラップ型になってしまいInt??となる
 */

let num9 = Optional("17")
let num10 = num9.map { val in
    Int(val)
}

type(of: num10) // Optional<Optional<Int>>.Type
// この 二重に 不確かな状態を一つにまとめてくれるのがFlatMapである
// 暗黙的アンラップ
// Wrapped!に関して

var aaa: String? = "aaa"
var bbb: String! = "bbb"

print(type(of: aaa))
print(type(of: bbb))
// Optional<String>
// Optional<String>
var ccc: String! = aaa
var ddd: String? = bbb

// 暗黙的アンラップはnilの場合errorになる
let eee: Int! = 1
eee + 1 // Int型ど同様に演算が可能
// var fff: Int! = nil
// fff + 1 // 値が入っていないため実行時Errorが起きる
/*
 Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value: file __lldb_expr_84/Optional.playground, line 224
 Playground execution failed:
 error: Execution was interrupted, reason: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0).
 The process has been left at the point where it was interrupted, use "thread return -x" to return to the state before expression evaluation.
 */

まとめ

  • 通常は バインディング、?? map flatMap を組み合わせて使うのがベター
  • 強制アンラップや暗黙的アンラップは実行時にErrorになるので避ける
  • !を多用しない
  • 常に考える

せっかく勉強したので、とりあえず記事にしました。
今回は他の記事と違って質低いので、読んでくれただけでありがたやです。

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