- 投稿日:2020-10-19T23:46:23+09:00
iOSアプリ開発:タイマーアプリ(5.アラーム、バイブレーションの実装)
記事内容
タイマーアプリを作成するためのポイントを複数記事に分けて掲載しています。
この記事では、タイマーのカウントダウンが0に到達したときに発動するアラームやバイブレーションの実装について掲載します。開発環境
- OS: macOS 10.15.x (Catalina)
- エディタ: Xcode 12.x
- 言語: Swift
- 主な使用ライブラリ: SwiftUI
手順概要
- TimeManagerでAudioToolboxライブラリをインポートする
- TimeManagerにデフォルトのアラーム音名とアラームIDのプロパティを追加する
- MainViewでAudioToolboxライブラリをインポートする
- MainViewのZStackの.onReceiveモディファイアにアラームの発動を実装する
- MainViewのZStackの.onReceiveモディファイアにバイブレーションの発動を実装する
手順詳細
1. TimeManagerでAudioToolboxライブラリをインポートする
TimeManagerに新たにAudioToolboxというライブラリをインポートします。
AudioToolboxライブラリには、主に効果音的サウンドに関するクラス、プロパティ、メソッドが含まれますので、タイマーのカウントダウンが0に達したときにアラーム音を慣らすためにこれを利用します。TimeManager.swiftimport 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.swiftimport 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.swiftimport 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.swiftstruct 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.swiftstruct 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)) {} } } } }次回は、アラームとバイブレーションのオン/オフなどを含む設定画面を作成していきます。
- 投稿日:2020-10-19T22:43:59+09:00
これだけ覚えておけばなんとかなる コードで書く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やレイアウトのタイミングについてもまた投稿したいと思います。
ありがとうございました。
- 投稿日:2020-10-19T21:39:33+09:00
さっそく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 リンク、リポジトリのリンク
- 投稿日:2020-10-19T21:13:11+09:00
iOS14のウィジェットのテンプレートをIntentConfigurationで作る。
iOS14で追加になったウィジェットのテンプレートをIntentConfigurationで作ります。
("ウィジェットを編集"メニューが表示される方)
できるだけXcodeの自動生成を使います。
テンプレートなので何の機能もありませんが、ウィジェットを量産するときの基礎になることを期待しています。Appの作成
まだUIKitで作っているアプリがほとんどだと思うので、
UIKit App Delegate
を選択しています。
名前はクレジットカードアプリを想定してLeaderCard
にしました。
Widget Extensionの作成
Include Configuration Intent
にチェックを入れる。(←ここ大事!)
チェックを入れないとStaticConfiguration
になります。
Widget名はLeaderCardWidget
にしました。
Intent Handlerの作成
Intents Extensionを選択して
Next
を押す。
Include UI Extension
をチェックしない。(←ここ大事!)
Starting Point
はNone
を選択。(←ここ大事!)
Intent Handler名はLeaderCardIntent
にしました。
intentdefinitionの設定
LeaderCardWidget
フォルダーの中に生成されているLeaderCardWidget.intentdefinision
を開く。
下方にある+
を押してNew Type
を選択。
タイプ名にCard
を設定する。Card
の中身は触らなくても良いです。
Parameters
Parameter
Parameterは小文字で
card
と設定する。(←ここ大事!)
自動生成されるクラスConfigurationIntent
のプロパティ名になっているからです。Display Name
Type
Card
を選択する。Configurable
チェックする。
Dynamic Options
チェックする。
Prompt Label
Target Membership
LeaderCardWidgetExtension
とLeaderCardIntent
にチェックする。(←ここ大事!)
Intent Handlerを最低限コーディングする
初期状態
IntentHandler.swiftclass 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
プロトコルを追加する。
そうすると、protocol stub
を追加するか聞かれるのでFix
を押す。
provideCardOptionsCollection
メソッドが追加される。
code
の部分にcompletion(nil, nil)
を書く。
(テンプレートなので何も機能しないです。)IntentHandler.swiftclass 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 } }完成
これでテンプレートが完成したはずです。
実行すると以下のような画面が表示されます。
これに肉付けして目的のウィジェットに近づけていくことになります。
GitHub
こちらに
Template repository
を作っておりますので、よろしければご利用ください。
iOS14-Widget-IntentConfiguration-template開発環境
- Xcode 12.1GM
- iOS 14.0 - 14.1
- 投稿日:2020-10-19T12:54:10+09:00
iOSアプリ開発:タイマーアプリ(4.カウントダウンの実装)
記事内容
タイマーアプリを作成するためのポイントを複数記事に分けて掲載しています。
この記事では、カウントダウンタイマーで実際に残り時間の表示を1秒毎に更新する手順について掲載します。開発環境
- OS: macOS 10.15.x (Catalina)
- エディタ: Xcode 12.x
- 言語: Swift
- 主な使用ライブラリ: SwiftUI
手順概要
- TimeManagerクラスにTimerクラスpublishメソッドを追加する
- MainViewの残り時間の表示を1秒毎に更新する
手順詳細
1. TimeManagerクラスにTimerクラスpublishメソッドを追加する
現時点では、スタートボタンを押しても、タイマーステータスは.runningに変わりますが、画面上はまだ残り時間がカウントダウンされません。画面上はPickerで設定した時間のまま止まっています。
そこで、このタイマーアプリの肝となる残り時間のカウントダウン表示を実装していきます。
SwiftUIライブラリにも含まれますが、Swift言語の一番ベースとなるFoundationライブラリに用意されているTimerというクラスのpublishというメソッドを利用します。
以下のように記述することで、publishメソッドの引数everyで指定した時間(1秒)ごとに発動するタイマーを変数timerに格納します。これをView側で利用することで、何らかのアクションを1秒毎に発動したいときにトリガーになってくれます。ここでは、タイマーアプリが残り時間を1秒毎に更新する際にトリガーとして利用していきます。
TimeManager.swiftclass 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.swiftstruct 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 } } } }これでようやくカウントダウンタイマーらしくなってきました。
次回は、アラームの実装をしていきます。
- 投稿日:2020-10-19T11:17:02+09:00
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のバグっぽい挙動なのですが、報告です
もし回避策がありましたら教えていただけるとありがたいです!
- 投稿日:2020-10-19T01:16:54+09:00
iOSアプリ開発:タイマーアプリ(3.スタート/ストップボタン、リセットボタン)
記事内容
タイマーアプリを作成するためのポイントを複数記事に分けて掲載しています。
この記事では、カウントダウンタイマーのメイン操作となるスタート、一時停止、リセット機能を実装するための手順を掲載します。開発環境
- OS: macOS 10.15.x (Catalina)
- エディタ: Xcode 12.x
- 言語: Swift
- 主な使用ライブラリ: SwiftUI
手順概要
- TimeManagerにタイマーのステータスを示すプロパティを作成する
- TimeManagerにタイマーのステータスを変更するメソッドを作成する
- ButtonsViewを作成する
- ButtonsViewにスタート/ストップボタンを作成する
- ButtonsViewにリセットボタンを作成する
- 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.swiftclass TimeManager: ObservableObject { //(他のプロパティ省略) //タイマーのステータス @Published var timerStatus: TimerStatus = .stopped //(メソッド省略)2. TimeManagerにタイマーのステータスを変更するメソッドを作成する
まだボタンを作成していませんが、先にそれぞれのボタンをタップしたときに、タイマーをスタートしたり、一時停止したり、完全に終了させるメソッドをそれぞれ作成します。言い換えると、これらのメソッドにより先に作成したタイマーステータスのプロパティの値が都度変更される形になります。
TimeManager.swiftclass 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.swiftimport 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.swiftstruct 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.swiftstruct 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.swiftstruct MainView: View { @EnvironmentObject var timeManager: TimeManager var body: some View { ZStack { if timeManager.timerStatus == .stopped { PickerView() } else { TimerView() } VStack { Spacer() ButtonsView() .padding(.bottom) } } } }次回は残り時間のカウントダウン表示を実装していきます。
- 投稿日:2020-10-19T00:50:58+09:00
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になるので避ける
- !を多用しない
- 常に考える
せっかく勉強したので、とりあえず記事にしました。
今回は他の記事と違って質低いので、読んでくれただけでありがたやです。