SwiftUIを学び始めて1ヶ月くらいたったので勉強がてらアプリを作ってみることにしました。
作成した流れと学習できたポイントなどをまとめていきたいと思います。
Swift学習や「SwiftUIでアプリ開発する時ってこんな感じなんだなぁ」と思ってもらえると幸いです。
アプリの概要
燃費計算アプリ
機能
- 走行距離と給油量を入れると燃費を表示
式:走行距離(km) ÷ 給油量(ℓ) = 燃費(km/ℓ)
- 距離と燃費、ガソリン単価を入れると必要な料金を表示
式:走行距離(km) ÷ 燃費(km/ℓ) × 単価(円) = 料金(円)
以上2つの機能をタブで切り替えられるようにしたい。
アプリ制作の手順
- XcodeでSwiftUIの新規プロジェクトを作成。
- それぞれの機能ごとにSwiftUIのViewファイルを作成
- 「走行距離と給油量を入れると燃費を表示」CalcNenpiViewを作成
- 「距離と燃費、ガソリン単価を入れると必要な料金を表示」CalcPriceViewを作成
- 親ビュー(ContentView)から2つのViewをTabViewを使って呼び出し
Xcodeのインストール方法!SwiftとSwiftUIの違い
作成する際に学んだポイント
今回はとてもシンプルで簡単なアプリを作ってみましたが、やはり初心者からすると細かい挙動やルールの理解が足りず苦戦したところもありました。
●ポイント
- TextFieldはString型
- String型を数値に変換する際の注意点
- プレビュー表示の際に初期値がないとエラー
- ダークモードで入力値が見えない
- ナンバーキーボードを閉じるボタンの実装が必要
- アプリアイコンの設定
TextFieldはString型
TextFieldでは今回数値を使いたかったのですがバインディングできる変数の型はString型しか許容していないようでした。
TextField(titleKey: LocalizedStringKey, text: Binding<String>)
String型を数値に変換する際の注意点
String型であることでこの後数値として計算式に埋め込むためにはInt型などへの変換が必要になりました。
しかし安直にキャスト(型変換)しようとするとエラーになってしまいます。これは文字列が必ずしも数値に変換できる文字列である保障がないためでした。
そのため今回は下記のような文字列を整数に変換する関数を定義し解決しました。
func changeNum (_ str:String) -> Int{
guard let num = Int(str) else {
// 文字列の場合
return 0
}
return num
}
【Swift】基本構文を復習!タプルや変数の使い方、型の注意点
プレビュー表示の際に初期値がないとエラー
これは当たり前と言えば当たり前なのですがプレビュー表示しようとした際に、変数によっては初期値がないとエラーを起こしてしまう場合がありました。
私の場合は2つのプロパティと1つの計算プロパティを定義していました。計算プロパティは燃費を計算した値を返すように作成しました。
@State var milage:String = "" // 走行距離
@State var refueling:String = "" // 給油量
milage
とrefueling
にはTextFieldから入力された値を格納したいので初期値を設定していません。
しかしこれが原因でプレビューしようとしても両者を元に計算している計算プロパティがブランク同士で計算をしようとしてエラーになっていました。
エラーの内容も警告とともに「Xcodeが予期せぬエラーで終了しました。」というものでなかなか原因の特定に苦労しました。
これを解決するためにまず計算プロパティではなく関数に変更しました。そして両者の値が空白の場合は0
を返すようにしました。
func calcNenpi (milage:String,refueling:String) -> Int{
if milage == "" || refueling == ""{
return 0
}
let milageNum = changeNum(milage)
let refuelingNum = changeNum(refueling)
let result = round(Double(milageNum / refuelingNum))
return Int(result)
}
ダークモードで入力値が見えない
TextFieldはユーザーが設定している環境(ライトモードかダークモード)によって自動で背景色を変更してくれるようです。
ですがそのせいで明示的に黒色を指定していた文字がダークモードの際に同化して識別できなくなってしまいました。
これはColorSchemeと@Environmentを併用することで解決できました。
@Environment(\.colorScheme) var colorScheme: ColorScheme
var body: some View {
TextField("km", text: $milage)
.foregroundColor(colorScheme == .dark ? Color.white : Color.black)
}
【Swift UI】ダークモード時にテキストの文字色を変更する方法!
ナンバーキーボードを閉じるボタンの実装が必要
TextFieldでの入力を数値のみに制限したかったため.keyboardType(.numberPad)
を指定しました。
ですが数字のみのキーボードにするとEnterキーがないため、キーボードを閉じることができない仕様になっているようです。
そのために閉じるボタンを自作する必要がありました。フォーカスを制御できる@FocusStateとtoolbarを使って作成しました。
struct ContentView: View {
@State var milage:String = ""
@FocusState var isActive:Bool
var body: some View {
TextField("km", text: $milage)
.textFieldStyle(RoundedBorderTextFieldStyle())
.keyboardType(.numberPad)
.focused($isActive)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer() // 右寄せにする
Button("閉じる") {
isActive = false // フォーカスを外す
}
}
}
}
}
【Swift UI】TextFieldのキーボードを閉じる方法と@FocusStateの使い方
アプリアイコンの設定
実際にリリースする予定は今のところないですが、本番同様をイメージしてアプリアイコンの設定をしてみました。
デザインはどうしても素人だと難しいので商用利用可能な無料アイコンサイト「Flat-Icon-Design」を用いています。
設定方法は意外と簡単でプロジェクトのAssetsの中にアップロードするだけでした。
いつか、実際に公開できるようになる時が楽しみです。
【Swift UI】Xcodeでアプリアイコンを設定する方法!
作ってみた感想
SwiftUIでは常に横にプレビューが表示されており、リアルタイムで更新することもできるのでスムーズに開発を進めることができました。
また仮想的なiPhoneや実機(自分のデバイス)へのビルドも可能なので実際にアプリを使用する環境でのテストも容易なのはありがたかったです。
今回のアプリ開発は初心者すぎて、思っていたより難しく、まだまだSwift(SwiftUI)への理解が必要だなと感じました。
ですがとても楽しかったので引き続き勉強がてらのアプリ開発を続けてみたいと思います。
ソースコード
- 走行距離と給油量を入れると燃費を表示
struct CalcNenpiView: View {
@State var milage:String = "" // 走行距離
@State var refueling:String = ""// 給油量
@Environment(\.colorScheme) var colorScheme: ColorScheme
func calcNenpi (milage:String,refueling:String) -> Int{
if milage == "" || refueling == ""{
return 0
}
let milageNum = changeNum(milage)
let refuelingNum = changeNum(refueling)
let result = round(Double(milageNum / refuelingNum))
return Int(result)
}
func changeNum (_ str:String) -> Int{
guard let num = Int(str) else {
// 文字列の場合
return 0
}
return num
}
var body: some View {
VStack {
Image(systemName: "fuelpump.circle")
.resizable(resizingMode: .stretch)
.frame(width: 150.0, height: 150.0)
.padding(.bottom,145.0)
.foregroundColor((calcNenpi(milage: milage, refueling: refueling) == 0) ? Color.white : Color.orange)
HStack(spacing: 20){
Text("走行距離")
.frame(width: 100)
.foregroundColor(milage.isEmpty ? Color.white : Color.orange)
TextField("km", text: $milage)
.frame(width: 100)
.textFieldStyle(RoundedBorderTextFieldStyle())
.keyboardType(.numberPad)
.multilineTextAlignment(TextAlignment.trailing)
.foregroundColor(colorScheme == .dark ? Color.white : Color.b)
}.frame(width: 200,alignment: .trailing)
HStack ( spacing: 20){
Text("給油量")
.frame(width: 100)
.foregroundColor(refueling.isEmpty ? Color.white : Color.orange)
TextField("ℓ",text: $refueling)
.frame(width: 100)
.textFieldStyle(RoundedBorderTextFieldStyle())
.keyboardType(.numberPad)
.multilineTextAlignment(TextAlignment.trailing)
.foregroundColor(Color.black)
}.frame(width: 200,alignment: .trailing)
HStack (alignment: .bottom,spacing: 20){
Text("燃費:")
Text("\(calcNenpi(milage: milage, refueling: refueling))")
.font(.title)
.multilineTextAlignment(.trailing)
.lineLimit(1)
.foregroundColor((calcNenpi(milage: milage, refueling: refueling) == 0) ? Color.white : Color.orange)
.frame(width: 150,alignment: .trailing)
Text("km/ℓ")
}.frame(width: 300, height: 80)
}.padding()
.frame( maxWidth: .infinity, maxHeight: .infinity)
.background(Color(red: 0.3, green: 0.3, blue: 0.3))
.foregroundColor(Color.white)
.ignoresSafeArea()
}
}
- 距離と燃費、ガソリン単価を入れると必要な料金を表示
struct CalcPriceView: View {
@State var distance:String = "" // 距離
@State var nenpi:String = "" // 燃費
@State var cost:String = "" // ガソリン単価
func calcPrice (distance:String,nenpi:String,cost:String) -> Int{
if distance == "" || nenpi == "" || cost == ""{
return 0
}
let distanceNum = changeNum(distance)
let nenpiNum = changeNum(nenpi)
let costNum = changeNum(cost)
let result = round(Double(distanceNum / nenpiNum * costNum))
return Int(result)
}
func changeNum (_ str:String) -> Int{
guard let num = Int(str) else {
// 文字列の場合
return 0
}
return num
}
var body: some View {
VStack {
Image(systemName: "car.circle")
.resizable(resizingMode: .stretch)
.frame(width: 150.0, height: 150.0)
.padding(.bottom,100.0)
.foregroundColor((calcPrice(distance: distance, nenpi: nenpi, cost: cost) == 0) ? Color.white : Color.orange)
HStack(spacing: 20){
Text("走行距離")
.frame(width: 100)
TextField("km", text: $distance)
.frame(width: 100)
.textFieldStyle(RoundedBorderTextFieldStyle())
.keyboardType(.numberPad)
.multilineTextAlignment(TextAlignment.trailing)
.foregroundColor(Color.gray)
}.frame(width: 200,alignment: .trailing)
HStack(spacing: 20){
Text("燃費")
.frame(width: 100)
TextField("km/ℓ", text: $nenpi)
.frame(width: 100)
.textFieldStyle(RoundedBorderTextFieldStyle())
.keyboardType(.numberPad)
.multilineTextAlignment(TextAlignment.trailing)
.foregroundColor(Color.gray)
}.frame(width: 200,alignment: .trailing)
HStack(spacing: 20){
Text("単価")
.frame(width: 100)
TextField("¥", text: $cost)
.frame(width: 100)
.textFieldStyle(RoundedBorderTextFieldStyle())
.keyboardType(.numberPad)
.multilineTextAlignment(TextAlignment.trailing)
.foregroundColor(Color.gray)
}.frame(width: 200,alignment: .trailing)
HStack (alignment: .bottom,spacing: 20){
Text("料金:")
Text("\(calcPrice(distance: distance, nenpi: nenpi, cost: cost))")
.font(.title)
.multilineTextAlignment(.trailing)
.lineLimit(1)
.foregroundColor((calcPrice(distance: distance, nenpi: nenpi, cost: cost) == 0) ? Color.white : Color.orange)
.frame(width: 150,alignment: .trailing)
Text("円")
}.frame(width: 300, height: 80)
}
.padding()
.frame( maxWidth: .infinity, maxHeight: .infinity)
.background(Color(red: 0.5, green: 0.6, blue: 0.5))
.foregroundColor(Color.white)
.ignoresSafeArea()
}
}
- 親ビュー(ContentView)
struct ContentView: View {
@State var selectedTag = 1
@FocusState var isInputActive:Bool // ナンバーパッドのフォーカス
var body: some View {
TabView(selection: $selectedTag){
CalcNenpiView().tabItem{
Image(systemName: "fuelpump.circle")
Text("Nenpi")
}.tag(1)
.focused($isInputActive)
CalcPriceView().tabItem{
Image(systemName: "car.circle")
Text("Price")
}.tag(2)
.focused($isInputActive)
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer() // 右寄せにする
Button("Done") {
isInputActive = false
}
}
}
}
}
拙い記事をご覧いただきありがとうございました。
まだまだ勉強中ですので至らぬ点や間違っている点があったら教えていただけると嬉しいです。