概要
簡単なタイマーアプリを作り、デベロッパー登録(有料)をせず自分のスマホにアプリをインストールします。
思いついたアプリを数時間で書いて実機で試してみたい人向けの記事になります。
この記事に含まれる内容
XcodeとSwiftUIでiOSのタイマーアプリを作る方法
Bardと初めての言語でペアプログラミングをする方法
自作アプリを実機に無料でインストールする方法
この記事に含まれない内容
プログラミングの一般的な知識
App Storeでリリースする方法
目次
1. はじめに
最近ランニングを始めました。
私はiPhone SE3を使っていますが、スマホリングに指を通して標準のタイマーアプリでラップタイムを計測するのがお気に入りです。
しかし不満もあります。ラップのボタンが若干押しづらい位置にある。またランニング中は画面を見る余裕がないため、ボタンを押せていたらバイブで通知してほしい。
探せば見つかりそうですが、UIは標準のものが好きだし、不要な機能は載せないでほしいです。
つまり俺俺ストップウォッチアプリが欲しい。
Swiftはほとんど触れたことがなかったので、勉強も兼ねて作ることにしました。
調べながら作業したため5~6時間かかりましたが、この記事に従えば思いついたものを1時間くらいで実機インストールできるようになると思います。
2. Bardとペアプロで最も単純なタイマーアプリを作る
Swiftの経験は5年前にチュートリアルをやった程度です。つまりすべて忘れた。
他の言語の経験から少し慣れれば書けるようになるだろうという予感はありましたが、ドキュメントの探し方から学ぶのは面倒だったので、チャットAIを使って骨格を作成することにしました。
普段はChatGPTを使うことが多いですが、今回はたまたまBardを使っています。
2.1. Xcodeからプロジェクトを作成
調べれば情報が無限に出てくる内容なので割愛します。
GUIはSwiftUIとStoryboardがありますが、StoryboardはチャットAIと相性悪そうなのでSwiftUIを使います。
以下のような記事を適当に参照してください。
またソースコードはGit管理していますが、大筋に関係ないためこれも割愛します。
2.2. とりあえずBardに書かせる
最初は真面目に文法を調べていましたが、作業効率に限界を感じたため直接聞くことにしました。
SwiftUIでタイマーアプリはどのように書けば良いですか?
import SwiftUI
struct TimerView: View {
@State private var time = 60
var body: some View {
// タイマーの時間を表示する
Text("\(time) 秒")
// タイマーを開始するボタン
Button(action: startTimer) {
Text("開始")
}
// タイマーを停止するボタン
Button(action: stopTimer) {
Text("停止")
}
}
private func startTimer() {
// タイマーを開始
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
// タイマーの時間を減らす
self.time -= 1
// タイマーの時間が 0 になったら、タイマーを停止する
if self.time <= 0 {
timer.invalidate()
}
}
}
private func stopTimer() {
// タイマーを停止
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
// タイマーを停止
timer.invalidate()
}
}
}
諸々間違いはありますが、動くコードが返ってきました。
Xcodeでプロジェクトを作成した後、初期状態ではProjectNameApp.swift
とContentView.swift
が存在すると思います。このうちContentView.swift
に、上記のコードを適用します。
import SwiftUI
import Foundation
struct ContentView: View {
@State private var time = 60
var body: some View {
// タイマーの時間を表示する
Text("\(time) 秒")
// タイマーを開始するボタン
Button(action: startTimer) {
Text("開始")
}
// タイマーを停止するボタン
Button(action: stopTimer) {
Text("停止")
}
}
private func startTimer() {
// タイマーを開始
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
// タイマーの時間を減らす
self.time -= 1
// タイマーの時間が 0 になったら、タイマーを停止する
if self.time <= 0 {
timer.invalidate()
}
}
}
private func stopTimer() {
// タイマーを停止
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
// タイマーを停止
timer.invalidate()
}
}
}
プレビュー画面で操作してみるとわかりますが、このアプリは停止ボタンが機能しません。
また開始ボタンを押せば押すほどタイマーが量産され、カウントダウンが加速します。
Swiftの作法が分からなくとも、これはタイマーの同じインスタンスを操作してないからだろうと予想つきます。
しかしSwiftのクラスメンバの書き方が分かりません。聞いてみましょう。
Timer型の参照をTimerViewのメンバに残したいのですが、どうすればいいですか?
import SwiftUI
struct TimerView: View {
@State private var time = 0
@State private var timer: Timer?
var body: some View {
// タイマーの時間を表示する
Text("\(time) ミリ秒")
// タイマーを開始するボタン
Button(action: startTimer) {
Text("開始")
}
// タイマーを停止するボタン
Button(action: stopTimer) {
Text("停止")
}
}
private func startTimer() {
// タイマーを開始
timer = Timer.scheduledTimer(withTimeInterval: 0.001, repeats: true) { timer in
// タイマーの時間を増やす
self.time += 1
// タイマーの時間が 0 になったら、タイマーを停止する
if self.time >= 1000 {
timer.invalidate()
}
}
}
private func stopTimer() {
// タイマーを停止
timer?.invalidate()
}
}
startTimer()
内のif条件が面白いことになっていますが、それはそれとしてメンバの書き方が分かりました。
?はjavascriptなどにも出てくるので予想はつきます。nullチェック関連でしょう。
教えてもらった書き方を参考にして、タイマーの開始・停止ができるようになりました。
ちなみに、Bardに聞いた後にウェブの方で@State
やSwiftのクラスメンバの書き方について調べて答え合わせもしています。チャットAIの強みはコードの骨格や用語を簡単に取得できるところにあるので、足りない部分は補ってあげます。
3. SwiftUIで俺俺タイマーを作る
「ボタンでタイマーを操作する」というタイマーアプリとしての最低限の要件は満たしました。
しかし作りたいのは俺俺タイマーです。標準タイマーをベースにしたいという構想があったため、まずはiOSの標準タイマーを再現することを目標にSwiftUIを学びます。
3.1. 背景を黒くする
ダークモードは良い文化です。まずは背景を黒くしてみましょう。
背景を黒くしたいです
背景を黒くするには、以下の手順で進めることができます。
1. ContentView()のbodyプロパティでZStack()を定義します。
2. ZStack()のbackgroundプロパティでColor()を定義して、色を黒にします。
例えば、以下のコードのように書くと、背景を黒にすることができます。
このあとに出てきたコードは間違ったものでした。
しかしヒントは提示してくれています。なにやらZStack
という概念があるらしい。
色々と聞き方を変えたり、ウェブで調べるなどして書き方を調査しました。
結果、次のようなコードが生まれました。
ZStack {
Color.black
// タイマーの時間を表示する
Text("\(time) ミリ秒")
// 開始ボタン
Button(action: startTimer) {
Text("開始")
}
// 停止ボタン
Button(action: stopTimer) {
Text("停止")
}
}
しかし、普段QtでUIを書いている身としては違和感がありました。
background
プロパティはないのか?と。聞いてみます。
Viewクラスはどんなプロパティを持ちますか?
Viewクラスは、以下のプロパティを持ちます。
- bodyプロパティ:このプロパティは、Viewクラスの主要なプロパティであり、Viewのコンテンツを定義します。
- environmentプロパティ:このプロパティは、Viewがレンダリングされる環境に関する情報を提供します。
- modifierプロパティ:このプロパティは、Viewに適用される修飾子を定義します。
- frameプロパティ:このプロパティは、Viewのフレームを定義します。
- alignmentプロパティ:このプロパティは、Viewのコンテンツの配置を定義します。
- intrinsicContentSizeプロパティ:このプロパティは、Viewのコンテンツに必要な最小サイズを定義します。
- debugDescriptionプロパティ:このプロパティは、Viewの説明を定義します。
...
無さそうです。しかしまだZStack
に納得いっていないため、具体的に聞いてみます。
背景として扱いたいUIのクラスを探す時、FrameやRectangleのようなクラスはありますか?またどういったクラスがUIのひとまとまりを示すクラスとして一般的ですか?
背景として扱いたいUIのクラスを探す場合、FrameやRectangleのようなクラスは適していません。
FrameやRectangleは、Viewのサイズと位置を定義するクラスであり、UIの背景を表現するものではありません。
背景として扱いたいUIのクラスとしては、以下のようなクラスが一般的です。
- Color:単色の背景を表現するクラスです。
- Image:画像の背景を表現するクラスです。
- Shape:図形の背景を表現するクラスです。
- Gradient:グラデーションの背景を表現するクラスです。
このあたりでSwiftの思想を理解してきたので、最後に自分の理解で確認をとってみました。
SwiftのUIはbackgroundのような属性は持たず、ZStackで背景とメインコンテンツを自分で重ねる必要があるという理解で合っていますか?
はい、その理解で合っています。SwiftのUIは、backgroundのような属性は持っていません。
background属性があると、UIの背景を簡単に変更することができるため、便利なように思えますが、
SwiftUIでは、背景とメインコンテンツを自分で重ねることで、より柔軟に背景を表現できるようにしています。
ZStack()を使用すると、複数のViewを重ねることができます。このため、ZStack()のchildrenプロパティで、
背景のViewとメインコンテンツのViewを定義することで、背景とメインコンテンツを簡単に重ねることができます。
例えば、以下のコードのように、ZStack()を使用して、背景とメインコンテンツを重ねることができます。
最近のチャットAIは間違ってる時にはっきりと否定してくれるため、ある程度この回答を信頼することにします。
そんなわけで、長引きましたが背景を黒くできました。
var body: some View {
ZStack {
Color.black
VStack {
// タイマーの時間を表示する
Text("\(time) 秒").foregroundStyle(Color.white)
// タイマーを開始するボタン
Button(action: startTimer) {
Text("開始")
}
// タイマーを停止するボタン
Button(action: stopTimer) {
Text("停止")
}
Button(action: resetTimer) {
Text("リセット")
}
}
}
}
3.2. 他のUIを配置する
思ったより前項が長くなったので他のUIについては端折ります。
背景と同じようにBardとペアプロで開発を進め、以下のようなコードを書くことでiOS標準のタイマーアプリによく似たUIが作成できました。
上から順に簡単な説明をします。
import SwiftUI
import UIKit
struct Lap: Identifiable {
var id: Int
var time: Double
}
struct SecToTime {
var time: Double
func hour() -> Int { return Int(time / 3600) }
func min() -> Int { return Int(time / 60) }
func sec() -> Int { return Int(time) % 60 }
func msec() -> Int { return Int(time * 100) % 100 }
func str() -> String {
var text = ""
text += hour() > 0 ? hour().formatted() + ":" : ""
text += (min() < 10 ? "0" : "" ) + min().formatted() + ":"
text += (sec() < 10 ? "0" : "" ) + sec().formatted() + "."
text += (msec() < 10 ? "0" : "" ) + msec().formatted()
return text
}
}
struct ContentView: View {
@State private var time = 0.0
@State private var laps: [Lap] = [] // ラップタイムの配列
@State private var timer: Timer?
@State var running = false
init() {
print("ContentView is created")
}
var body: some View {
ZStack {
Color.black
VStack {
Text(SecToTime(time: time).str())
.font(.custom("HelveticaNeue-Thin", size: 80))
.foregroundStyle(Color.white)
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height / 2)
HStackLayout(spacing: 100) {
let size = 100.0
Button(action: running ? lapTimer : resetTimer) {
ZStack {
Circle()
.stroke(Color.black, lineWidth: 2)
.frame(width: size * 0.9, height: size * 0.9)
Text(running ? "ラップ" : "リセット")
.foregroundColor(time > 0 ? Color.white : Color(hue: 0, saturation: 0, brightness: 0.5))
}
}
.frame(width: size, height: size)
.background(time > 0 ? Color(hue: 0, saturation: 0, brightness: 0.2) : Color(hue: 0, saturation: 0, brightness: 0.1))
.cornerRadius(size / 2)
Button(action: running ? stopTimer : startTimer) {
ZStack {
Circle()
.stroke(Color.black, lineWidth: 2)
.frame(width: size * 0.9, height: size * 0.9)
Text(running ? "停止" : "開始")
.foregroundColor(running ? Color(red: 1, green: 0.4, blue: 0.1) : Color(red: 0.1, green: 1, blue: 0.7))
}
}
.frame(width: size, height: size)
.background(running ? Color(red: 0.25, green: 0, blue: 0) : Color(red: 0, green: 0.21, blue: 0))
.cornerRadius(size / 2)
}
.padding(EdgeInsets(top: 15, leading: 10, bottom: 15, trailing: 10))
Rectangle()
.frame(maxWidth: .infinity, maxHeight: 1)
.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
.foregroundColor(Color.init(hue: 0, saturation: 0, brightness: 0.15))
// 縦向きのスクロールエリア
ScrollView(.vertical) {
VStack {
ForEach(laps) { lap in
VStack {
// ラップn 00.00のテキスト
HStack {
Text("ラップ\(lap.id)")
.frame(maxWidth: .infinity, alignment: .leading)
Text(String(format: "%2.2f", lap.time))
.frame(maxWidth: .infinity, alignment: .trailing)
}
.frame(height: 18)
.foregroundColor(Color.white)
// 罫線
Rectangle()
.frame(maxWidth: .infinity, maxHeight: 1)
.foregroundColor(Color.init(hue: 0, saturation: 0, brightness: 0.15))
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity) // 画面に収まる範囲で最大化する
.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)) // marginはないっぽいのでpaddingを使う
}
}
}
private func startTimer() {
if (!running) {
timer = Timer.scheduledTimer(withTimeInterval: 1/100, repeats: true) { timer in
self.time += 1/100
}
running = true
}
}
private func stopTimer() {
if (running) {
timer?.invalidate()
running = false
}
}
private func resetTimer() {
laps.removeAll() // ラップタイムのログをすべて削除する
self.time = 0.0 // 表示時刻をリセットする
}
private func lapTimer() {
laps.append(Lap(id: laps.count, time: self.time)) // 配列にラップタイムを追加する
}
}
#Preview {
ContentView()
}
struct Lap
Swiftではforeachの対象が限られるらしいので、ラップタイムのような配列ベースのGUIを作るためにはIdentificable
などを継承した構造体を定義しておく必要があります。
struct SecToTime
変な実装になってしまった、time
を表示用の文字列に変換する構造体です。関数の方がいいと思います。
面倒なので直していないです。
.font(.custom("HelveticaNeue-Thin", size: 80))
フォントファミリーを指定して、0~9の数字が等幅で表示できるフォントを選んでいます。
これが等幅でないと、タイマーの表示が変わるたびに文字が横に移動してしまいます。
以下から適当にそれっぽいのを選びました。
AppleSDGothicNeo系はなぜか等幅のフォントがありません。
UIScreen.main.bounds.width
スマホの画面横幅を意味します。
UIScreen.main.bounds.heightなら縦幅です。
時間の部分は画面のおよそ上半分をすべて占有していたのでこのように指定しました。
Button(action: running ? lapTimer : resetTimer)
何箇所かありますが、三項間演算子で表示UIを切り替えています。
馴染みのある人には説明不要かと思いますが、この文法が分からない人は「三項間演算子」で調べてください。
3.3. 俺俺機能を追加する
個人的に欲しかった機能、また今後使う予定のある機能として、以下の変更を加えました。
デバイスにデータを保存する
将来的に、毎日のラップタイムを勝手に記録しておいてくれると見返したりできて便利そうです。
どんなデータを保存するか決めるのは丁寧にやりたいので、一旦Double値をひとつ保存、読み込みするだけの機能として追加しておきます。
こちらを参考に標準モジュールのUserDefaults
から保存、読み込みをします。
jsonなども保存できるらしく、デフォルトでこれだけのことができるのは便利だと感じました。
struct ContentView: View {
init() {
...
let lastTime = UserDefaults.standard.double(forKey: "LastTime")
print(lastTime)
}
...
private func stopTimer() {
if (running) {
UserDefaults.standard.set(time, forKey: "LastTime")
...
}
}
}
ラップボタンを押したときスマホを振動させる
UIImpactFeedbackGenerator
を使いました。
いくつか手法があるらしいです。(参考)
struct ContentView: View {
...
let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
init() {
...
feedbackGenerator.prepare() // 呼んでおくと振動が安定するらしい
}
...
private func lapTimer() {
laps.append(Lap(id: laps.count, time: self.time))
feedbackGenerator.impactOccurred()
}
}
以下のような書き方でも動作するとは思いますが、コンストラクタの呼び出しは最低限にしたいです。
この辺はまだswiftの作法を理解していないため適当です。
private func lapTimer() {
laps.append(Lap(id: laps.count, time: self.time))
let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
feedbackGenerator.prepare()
feedbackGenerator.impactOccurred()
}
4. テストとして実機にインストールする
本題です。と言っても以下の記事を参考にしただけです。
アドホック版を作成するには年額100ドルのApple Developer Programへ加入する必要があるものと思っていましたが、今はApple IDさえあれば一週間ほど動作するアプリをインストールできるとのこと。
私はApple Developer Programへ加入してしまっていますが、おそらく未加入でも以下の手順でインストールできるはずです。
2023年11月現在では見た目が変わった部分もあると思うので、変わった部分だけ画像を貼っておきます。
4.1. Xcodeから署名っぽいことをする
Xcode -> Settings... を選択
あとは参考記事に従って証明書を作成します。
Certificateを作成するところまで進めると、以下の部分で手が止まりました。
「General」の「Signing」に戻りTeamを今設定したApple IDにします。
Xcode 15.0.1にそんなものは見当たりませんでした。
どうやらxcodeprojectの設定の方に移動したようです。
4.2. ビルドのターゲットバージョンを設定する
スマホとmacを繋いでさあビルドだ!と実行したら、実機のiOSバージョンがデプロイターゲットのバージョンより低いとエラーが吐かれました。
何の問題もなくビルドが通り、スマホにアプリをインストールできた人は飛ばしてください。
私と同様バージョン違いで怒られた人は、こちらを参考にバージョンを合わせてください。
4.3. アプリを実行する
インストールしたアプリを開こうとすると、エラーダイアログが出てきて中々開けません。
こちらなどを参考に設定しましたが、この記事はそこそこ古いのでiOSのUIがかなり変わっていました。
デベロッパモードが必要と出る場合
xcodeからインストールしたアプリを実行するため、まずスマホをデベロッパモードにします。
「プライバシーとセキュリティ」 -> 「デベロッパモード」から切り替え、スマホを再起動します。
信頼されていないデベロッパと出る場合
「一般」 -> 「VPNとデバイス管理」からアプリを信頼するよう設定します。
私の場合は以上の作業を経てアプリを実行できるようになりました。
他のエラーに関してはそれぞれ調べてみてください。
5. おわりに
2~4章それぞれについて、断片的に情報はあるものの、ひとまとめになっている記事が無いように感じたので書きました。
iOSアプリは作るのはともかく試すのが面倒という印象があったので、サクッとスマホに入れる方法が分かって良かったです。同じような悩みを持つ方のお役に立てれば幸いです。