完成品
旅行のスケジュール計画・管理アプリ
こんな感じ。
※❓のエラーが出ています…修正できず一旦失礼します。
機能
- 旅行の管理
- ユーザーは新しい旅行を作成し、旅行のタイトルと日付範囲を設定できます。
- 既存の旅行を選択して、その旅行に関連するスケジュールを管理できます。
- スケジュールの追加と管理
- 各旅行に対して、日付ごとにスケジュールを追加できます。
- スケジュールにはタイトル、メモ、ジャンル(移動、ごはん、観光、ホテル)を設定できます。
- スケジュールの開始時間と終了時間を設定し、時間のショートカットボタンを使用して簡単に時間を調整できます。
- スケジュールの削除
- スケジュールを削除するための機能があり、削除前に確認のアラートが表示されます。
- 検索機能
- 旅行タイトルを検索して、特定の旅行を素早く見つけることができます。
UI
- ナビゲーションとタブビュー
- NavigationViewとTabViewを使用して、ユーザーが簡単に異なるビュー間を移動できるようにしています。
- ピッカーとリスト
- Pickerを使用して旅行を選択し、Listを使用してスケジュールを日付ごとに表示します。
- モーダルシート
- 新しい旅行を作成するためのシートが表示され、ユーザーは旅行の詳細を入力できます。
- アラート
- 入力エラーや削除確認のためのアラートを通知します。
- レスポンシブデザイン
- VStackやHStackを使用して、様々なデバイスサイズに適応するように設計しています。
作成の背景
- 以下の記事を読み、影響されたことが大きいです。
- 直近1年間Pythonの学習を進めていましたが、今後さらに自分のスキルの幅を広げるため他のプログラミング言語も学習していきたいと思っていました。
- そして、この機会にどうせならこれまでに全く触ったことのないSwiftにチャレンジしようと思いました。(あわよくばアプリを公開したいと思っていたのですが、今のところ予定はありません。)
- 1週間程度、Swiftに関する知識をQiita記事やYoutubeでインプットしたのちに、GPTなどを使いながら、本アプリを開発しています。
使用ツール
- Xcode
- iPhone14 (開発者モードをオン)
コード
以下、コード全文です。
実際のコードーーーーーーーーーーーーーーーーーーーーーーーー
import SwiftUI
// Schedule型の定義
struct Schedule: Identifiable, Hashable {
let id = UUID()
var title: String
var memo: String
var genre: String // ジャンルを追加
init(title: String, memo: String, genre: String) {
self.title = title
self.memo = memo
self.genre = genre
}
// ジャンルに基づく絵文字や色を返す
var emoji: String {
switch genre {
case "🚗移動": return "🚗"
case "🍽ごはん": return "🍽"
case "🏞観光": return "🏞"
case "🏨ホテル": return "🏨"
default: return "❓"
}
}
}
// MainViewの定義
struct MainView: View {
@Binding var trips: [String: [String: [Schedule]]]
@Binding var selectedTrip: String
var body: some View {
NavigationView {
TabView {
ContentView(trips: $trips, selectedTrip: $selectedTrip)
.tabItem {
Label(selectedTrip.isEmpty ? "旅行" : selectedTrip, systemImage: "airplane")
}
}
}
}
}
// ContentViewの定義
struct ContentView: View {
@Binding var trips: [String: [String: [Schedule]]]
@Binding var selectedTrip: String
@State private var newTripName = ""
@State private var showNewTripSheet = false
@State private var departureDate = Date()
@State private var returnDate = Date()
@State private var selectedDate = Date()
@State private var startTime = Date()
@State private var endTime = Date()
@State private var newSchedule = ""
@State private var newMemo = ""
@State private var showAlert = false
@State private var alertMessage = ""
@State private var scheduleToDelete: (String, IndexSet)? = nil
@State private var searchText = ""
var filteredTrips: [String] {
if searchText.isEmpty {
return Array(trips.keys)
} else {
return trips.keys.filter { $0.contains(searchText) }
}
}
var body: some View {
NavigationView {
VStack {
TextField("検索", text: $searchText)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
TripPicker(trips: $trips, selectedTrip: $selectedTrip, showNewTripSheet: $showNewTripSheet, filteredTrips: filteredTrips)
if let dates = trips[selectedTrip] {
ScheduleListView(dates: dates, selectedTrip: $selectedTrip, trips: $trips, showAlert: $showAlert, scheduleToDelete: $scheduleToDelete)
} else {
Text("旅行を選択または作成してください")
.padding()
}
if !selectedTrip.isEmpty && trips[selectedTrip] != nil {
ScheduleInputView(selectedDate: $selectedDate, departureDate: $departureDate, returnDate: $returnDate, startTime: $startTime, endTime: $endTime, newSchedule: $newSchedule, newMemo: $newMemo, trips: $trips, selectedTrip: $selectedTrip, alertMessage: $alertMessage, showAlert: $showAlert)
}
}
.navigationTitle("旅行スケジュール一覧")
.toolbar {
ToolbarItem {
Button(action: {
showNewTripSheet = true
}) {
Text("新しい旅行を作成")
}
}
ToolbarItem {
Button(action: {
if !selectedTrip.isEmpty {
trips.removeValue(forKey: selectedTrip)
selectedTrip = trips.keys.first ?? ""
}
}) {
Image(systemName: "trash")
.foregroundColor(.red)
}
}
}
.sheet(isPresented: $showNewTripSheet) {
NewTripSheet(trips: $trips, newTripName: $newTripName, departureDate: $departureDate, returnDate: $returnDate, selectedTrip: $selectedTrip, showNewTripSheet: $showNewTripSheet, alertMessage: $alertMessage, showAlert: $showAlert)
}
.alert(isPresented: $showAlert) {
Alert(
title: Text("入力エラー"),
message: Text(alertMessage),
dismissButton: .default(Text("OK"))
)
}
}
}
}
// TripPickerの定義
struct TripPicker: View {
@Binding var trips: [String: [String: [Schedule]]]
@Binding var selectedTrip: String
@Binding var showNewTripSheet: Bool
var filteredTrips: [String]
var body: some View {
if !filteredTrips.isEmpty {
Picker("旅行を選択", selection: $selectedTrip) {
ForEach(filteredTrips, id: \.self) { trip in
Text(trip).tag(trip)
}
}
.pickerStyle(MenuPickerStyle())
.padding()
.onChange(of: selectedTrip) { newValue in
if newValue == "新しい旅行を作成" {
showNewTripSheet = true
selectedTrip = trips.keys.first ?? ""
}
}
}
}
}
struct ScheduleListView: View {
var dates: [String: [Schedule]]
@Binding var selectedTrip: String
@Binding var trips: [String: [String: [Schedule]]]
@Binding var showAlert: Bool
@Binding var scheduleToDelete: (String, IndexSet)?
var body: some View {
List {
ForEach(dates.keys.sorted(), id: \.self) { date in
Section(header: Text(date)) {
ForEach(dates[date] ?? [], id: \.self) { schedule in
HStack {
if let tripSchedules = trips[selectedTrip],
let daySchedules = tripSchedules[date],
let index = daySchedules.firstIndex(of: schedule) {
let binding = Binding<Schedule>(
get: { trips[selectedTrip]![date]![index] },
set: { trips[selectedTrip]![date]![index] = $0 }
)
NavigationLink(destination: DetailView(schedule: binding)) {
Text("\(schedule.emoji) \(schedule.title)")
}
}
Spacer()
Button(action: {
if let index = dates[date]?.firstIndex(of: schedule) {
scheduleToDelete = (date, IndexSet(integer: index))
showAlert = true
}
}) {
Image(systemName: "trash")
.foregroundColor(.red)
}
}
}
}
}
}
.alert(isPresented: $showAlert) {
Alert(
title: Text("削除確認"),
message: Text("本当に削除していいですか?"),
primaryButton: .destructive(Text("削除")) {
if let (date, indexSet) = scheduleToDelete {
deleteSchedule(date: date, at: indexSet)
}
},
secondaryButton: .cancel(Text("キャンセル"))
)
}
}
func deleteSchedule(date: String, at offsets: IndexSet) {
trips[selectedTrip]?[date]?.remove(atOffsets: offsets)
}
}
// ScheduleInputViewの定義
import SwiftUI
struct ScheduleInputView: View {
@Binding var selectedDate: Date
@Binding var departureDate: Date
@Binding var returnDate: Date
@Binding var startTime: Date
@Binding var endTime: Date
@Binding var newSchedule: String
@Binding var newMemo: String
@Binding var trips: [String: [String: [Schedule]]]
@Binding var selectedTrip: String
@Binding var alertMessage: String
@Binding var showAlert: Bool
@State private var selectedGenre = "🚗移動"
@State private var isExpanded = false // ドロワーの状態
let genres = ["🚗移動", "🍽️ごはん", "🏞️観光", "🏨ホテル"]
var body: some View {
VStack {
ScrollView { // 縦スクロールを有効に
VStack(spacing: 20) {
if isExpanded {
VStack(spacing: 20) {
// 閉じるボタンを常に上に表示
HStack {
Spacer()
Button(action: {
isExpanded.toggle()
}) {
Text("閉じる")
.foregroundColor(.blue)
}
}
.padding(.horizontal)
Picker("ジャンルを選択", selection: $selectedGenre) {
ForEach(genres, id: \.self) { genre in
Text(genre).tag(genre)
}
}
.pickerStyle(SegmentedPickerStyle())
.padding()
TextField("スケジュールのタイトル", text: $newSchedule)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding(.horizontal)
DatePicker("日付を選択", selection: $selectedDate, in: departureDate...returnDate, displayedComponents: .date)
.datePickerStyle(CompactDatePickerStyle())
.padding(.horizontal)
DatePicker("開始時間", selection: $startTime, displayedComponents: .hourAndMinute)
.padding(.horizontal)
// 終了時間のショートカットボタンに区切りを追加
HStack(spacing: 10) {
ForEach([30, 60, 90, 120, 180], id: \.self) { minutes in
Button(action: {
endTime = Calendar.current.date(byAdding: .minute, value: minutes, to: startTime) ?? endTime
}) {
Text(minutes == 30 ? "30分" : "\(minutes / 60)時間\(minutes % 60 > 0 ? "半" : "")")
.frame(minWidth: 60)
.padding(5)
.background(Color.blue.opacity(0.2))
.cornerRadius(5)
}
}
}
.padding(.horizontal)
DatePicker("終了時間", selection: $endTime, displayedComponents: .hourAndMinute)
.padding(.horizontal)
TextField("メモを追加", text: $newMemo)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding(.horizontal)
Button(action: {
if !newSchedule.isEmpty {
let timeFormatter = DateFormatter()
timeFormatter.dateFormat = "HH:mm"
let schedule = Schedule(
title: "\(timeFormatter.string(from: startTime))~\(timeFormatter.string(from: endTime)) \(selectedGenre): \(newSchedule)",
memo: newMemo,
genre: selectedGenre
)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy/MM/dd"
let dateString = dateFormatter.string(from: selectedDate)
if trips[selectedTrip]?[dateString] != nil {
trips[selectedTrip]?[dateString]?.append(schedule)
} else {
trips[selectedTrip]?[dateString] = [schedule]
}
newSchedule = ""
newMemo = ""
alertMessage = ""
} else {
alertMessage = "スケジュールを入力してください。"
showAlert = true
}
}) {
Text("スケジュールを追加")
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
.padding(.horizontal)
}
.padding(.top)
.background(RoundedRectangle(cornerRadius: 10).fill(Color(UIColor.systemGray6)))
.padding()
}
}
.frame(maxWidth: .infinity) // 画面幅に収まるように調整
}
// 新しいスケジュールボタンを画面の最下部に配置
Button(action: {
isExpanded.toggle()
}) {
Text("新しいスケジュール")
.font(.headline)
.foregroundColor(.blue)
.padding()
.background(Color(UIColor.systemGray5))
.cornerRadius(10)
}
.padding()
}
}
}
// NewTripSheetの定義
struct NewTripSheet: View {
@Binding var trips: [String: [String: [Schedule]]]
@Binding var newTripName: String
@Binding var departureDate: Date
@Binding var returnDate: Date
@Binding var selectedTrip: String
@Binding var showNewTripSheet: Bool
@Binding var alertMessage: String
@Binding var showAlert: Bool
var body: some View {
VStack {
HStack {
Spacer()
Button(action: {
showNewTripSheet = false
}) {
Text("閉じる")
.font(.caption)
.padding(5)
}
}
Text("新しい旅行のタイトルと日付")
.font(.headline)
.padding()
TextField("新しい旅行のタイトル", text: $newTripName)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
Form {
DatePicker("出発日", selection: $departureDate, in: Date()..., displayedComponents: .date)
.datePickerStyle(GraphicalDatePickerStyle())
.onAppear {
departureDate = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date()
returnDate = departureDate
}
.onChange(of: departureDate) { newValue in
if returnDate < newValue {
returnDate = newValue
}
}
Text("選択された出発日: \(formattedDate(departureDate))")
.padding(.bottom, 5)
DatePicker("帰着日", selection: $returnDate, in: departureDate..., displayedComponents: .date)
.datePickerStyle(GraphicalDatePickerStyle())
Text("選択された帰着日: \(formattedDate(returnDate))")
.padding(.bottom, 5)
}
Text("選択された日付: \(formattedDateRange())")
.padding()
if !alertMessage.isEmpty {
Text(alertMessage)
.foregroundColor(.red)
.padding(.bottom, 5)
}
Button(action: {
guard !newTripName.isEmpty, departureDate <= returnDate else {
alertMessage = "旅行のタイトルを入力してください。"
return
}
let dates = generateDateRange(from: departureDate, to: returnDate)
trips[newTripName] = [:]
for date in dates {
trips[newTripName]?[date] = []
}
selectedTrip = newTripName
newTripName = ""
showNewTripSheet = false
alertMessage = ""
}) {
Text("旅行を追加")
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
.shadow(radius: 5)
}
.padding()
}
.padding()
}
func formattedDate(_ date: Date) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy/MM/dd"
return dateFormatter.string(from: date)
}
func formattedDateRange() -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy/MM/dd"
return "\(dateFormatter.string(from: departureDate)) - \(dateFormatter.string(from: returnDate))"
}
func generateDateRange(from startDate: Date, to endDate: Date) -> [String] {
var dates = [String]()
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy/MM/dd"
var currentDate = startDate
while currentDate <= endDate {
dates.append(dateFormatter.string(from: currentDate))
currentDate = Calendar.current.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate
}
return dates
}
}
struct DetailView: View {
@Binding var schedule: Schedule
var body: some View {
VStack {
Text("\(schedule.title) の詳細")
.font(.headline)
TextEditor(text: $schedule.memo)
.border(Color.gray, width: 1)
.padding()
}
.navigationTitle(schedule.title)
}
}
主な構造体・ビュー種類
-
Schedule構造体
- IdentifiableとHashableプロトコルに準拠し、スケジュールのタイトル、メモ、ジャンルを保持
- ジャンルに基づいて絵文字を返すプロパティemoji
-
MainView
- NavigationViewとTabViewを使用して、アプリのメインビューを構成
- ContentViewをタブとして表示
-
ContentView
- 旅行のリストを表示し、新しい旅行を作成するためのUIを提供
- TripPickerを使用して旅行を選択し、ScheduleListViewでスケジュールを表示
- ScheduleInputViewを使用して新しいスケジュールを追加
-
TripPicker
- 旅行を選択するためのピッカーを提供
-
ScheduleListView
- 選択された旅行の日付ごとにスケジュールをリスト表示
- スケジュールを削除するためのボタンを提供
-
ScheduleInputView
- 新しいスケジュールを入力するためのフォームを提供
- スケジュールのタイトル、日付、開始時間、終了時間、メモを入力可能
-
NewTripSheet
- 新しい旅行を作成するためのシートを表示
- 旅行のタイトルと日付範囲を設定
-
DetailView
- スケジュールの詳細を表示し、メモを編集するためのビュー
使用している主なswiftコード
入門レベル
- 変数と定数の定義
letは定数を定義するために使用され、一度値を設定すると変更できません。
varは変数を定義し、値を変更できます
let id = UUID() // 一意の識別子を生成する定数
var title: String // 変更可能な文字列プロパティ
- 構造体の定義
structはデータをまとめるための基本的な方法です。
プロパティとメソッドを持つことができます。
Identifiableプロトコルは、リスト表示などで一意に識別するために使用されます。
Hashableプロトコルは、コレクション内での重複を避けるために使用されます。
struct Schedule: Identifiable, Hashable {
let id = UUID()
var title: String
var memo: String
var genre: String
}
- 基本的なUI要素:
Textは文字列を表示するためのビューです。
Buttonはユーザーがタップできるインタラクティブな要素です。
Text("新しいスケジュール")
Button(action: {
// アクション
}) {
Text("スケジュールを追加")
}
基本レベル
-
プロトコルの使用
プロトコルは、特定の機能を提供するための契約です。IdentifiableやHashableは、構造体が特定の機能を持つことを保証します。 -
SwiftUIのビュー構造
Viewプロトコルに準拠することで、カスタムビューを作成できます。
bodyプロパティでビューの内容を定義します。
struct MainView: View {
var body: some View {
NavigationView {
// ビューの内容
}
}
}
- データバインディング
@'Bindingは、親ビューから渡されたデータを子ビューで使用し、変更を親ビューに反映させるために使用します。
struct ContentView: View {
@Binding var trips: [String: [String: [Schedule]]]
// ビューの内容
}
- 状態管理
@'Stateは、ビュー内での一時的な状態を管理するために使用します。ビューが再描画されるときに状態が保持されます。
@State private var newTripName = ""
応用レベル
- ビューの組み合わせとレイアウト
NavigationViewは、階層的なナビゲーションを提供します。TabViewは、タブを使ったナビゲーションを提供します。
NavigationView {
TabView {
ContentView(trips: $trips, selectedTrip: $selectedTrip)
.tabItem {
Label(selectedTrip.isEmpty ? "旅行" : selectedTrip, systemImage: "airplane")
}
}
}
- カスタムビューの作成:
複数のビューを組み合わせて再利用可能なカスタムビューを作成します。これにより、コードの再利用性が向上します。
struct TripPicker: View {
@Binding var trips: [String: [String: [Schedule]]]
// ビューの内容
}
- 条件付きロジックとビューの更新:
if文を使って、条件に応じてビューを表示したり、更新したりします。
if !selectedTrip.isEmpty && trips[selectedTrip] != nil {
// ビューの内容
}
- アラートとシートの使用
Alertは、ユーザーに重要な情報を通知するために使用します。Sheetは、モーダルビューを表示するために使用します。
.alert(isPresented: $showAlert) {
Alert(
title: Text("入力エラー"),
message: Text(alertMessage),
dismissButton: .default(Text("OK"))
)
}
学び・感想
初めてのSwift開発を通じて、改めて0から新しいものを作る楽しさとチャレンジの達成感を味わうことができました。いろいろな学びがありますが、主には以下の3つです。
1. 宣言的UIの新鮮さ
SwiftUIを使った宣言的なUI構築は、PythonでのGUI開発とは異なる新鮮な体験でした。
UIの状態をコードで直接表現できるため、編集が直感的にすぐ行えて、プレビュー機能を使って即結果を確認できるのも非常に便利でした。知らないとできない要素が多く感じるのもありますが、ここはGPTなどを使えば、引き出しを増やしていけると思います。
struct MainView: View { // SwiftUIのビューを定義するための構造体。Viewプロトコルに準拠し、bodyプロパティを持つ必要があり。
var body: some View { // ビューのUIを定義
NavigationView { // 階層的なナビゲーションを提供
TabView { // タブを使ったナビゲーションを提供
ContentView(trips: $trips, selectedTrip: $selectedTrip) // MainView内で表示されるカスタムビュー
.tabItem { // タブバーに表示されるアイテムを定義
Label(selectedTrip.isEmpty ? "旅行" : selectedTrip, systemImage: "airplane")
}
}
}
}
}
SwiftUIを使用してUIを宣言型で構築することで、この部分がアプリの一部としてどう機能するかを直感的に理解できます。
2. 静的型付けの安心感
Swiftの静的型付けという特徴は、コードを書く際にかなり安心でした。
思えば静的型付けをちゃんと実感できたのは今回が初めてでした。
コーディングをする際、Schedule構造体のプロパティに型を明示することで型に関するエラーをコンパイル時に発見できましたし、不要な凡ミスを未然に防ぐこともできました。Pythonの動的型付けの柔軟さもきっとそれはそれで魅力なんですが、Swiftの静的型付けが、特に大規模なプロジェクトでの信頼性を高めると言われるのはその通りだなと実感しました。
#pythonの動的型付け
x = 10 # xは整数型
x = "Hello" # xは文字列型に変更
#swiftの静的型付け
var x: Int = 10 // xは整数型
// x = "Hello" // エラー: 文字列を整数型の変数に代入できない
3.データバインディングとは
データバインディングは、SwiftUIの大きな特徴の一つで、UIとデータの同期を簡単に行うことができます。これにより、常にユーザーインターフェースへ最新のデータを反映することができるため、コードの可読性が向上します。
実際にコードを書いてみて、やはりSwiftがAppleのプラットフォームに最適化されているという理由が大きいなと感じました。Pythonだとデータの変更に応じて、明示的にコードを書く必要も多い気がします。
struct MainView: View {
// 親ビューから受け取るデータ。tripsは旅行の情報、selectedTripは選択された旅行名。
@Binding var trips: [String: [String: [Schedule]]]
@Binding var selectedTrip: String
var body: some View {
// 画面間の移動を可能にするビュー。
NavigationView {
// タブバーを表示するビュー。
TabView {
// ContentViewを表示し、tripsとselectedTripを渡す。
ContentView(trips: $trips, selectedTrip: $selectedTrip)
// タブのラベルを設定。旅行名が空なら「旅行」と表示。
.tabItem {
Label(selectedTrip.isEmpty ? "旅行" : selectedTrip, systemImage: "airplane")
}
}
}
}
}
@'State
-
定義
- @'Stateは、ビューの内部で状態を管理するためのプロパティラッパーです。
ビューの状態が変わると、SwiftUIは自動的にビューを再描画します。
- @'Stateは、ビューの内部で状態を管理するためのプロパティラッパーです。
-
特徴
- 内部状態: @'Stateは、ビューの内部でのみ使用される状態を管理します。他のビューから直接アクセスすることはできません。
- 再描画: @'Stateの値が変更されると、ビューが再描画され、UIが更新されます。
-
例:
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack {
Text("Count: \(count)")
Button("Increment") {
count += 1
}
}
}
}
この例では、countは@'Stateで管理されており、ボタンを押すとcountが増加し、テキストが更新されます。
さいごに
今回の開発を通じて得た経験・知識を基に、引き続きさらに複雑なアプリや新しい言語に挑戦してみたいと思います。特に、データの永続化やネットワーク通信など、次のステップに進むための技術を学びたいです。チーム開発もしてみたいなぁ。。