iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

SwiftUIで「推し活カレンダー」アプリを作ってみた

この記事はiimon Advent Calendar 2025 20日目の記事となります!

はじめに

こんにちは!新卒エンジニアのつかちゃんです!今回は私の趣味を交えたアプリを作ってみました!

実は幼少期からK-POPが大好きで、最初は日本語を今風の韓国語に翻訳してくれるキーボード拡張アプリを作ろうとしていました。

推しにメッセージ送るとき自然な韓国語で打ちたいじゃないですか。Papago翻訳だとちょっと硬い表現になりがちなので、若者言葉っぽく変換してくれるやつが欲しかったんです。

ただ、これがなかなかうまくいかず...

  • OpenAI APIとの連携でつまずく
  • キーボード拡張は実機ビルドが必須
  • 実機ビルドの証明書設定で沼にハマる
  • シミュレーターでは動作確認すらできない

という状況で、アドベントカレンダーの締め切りも迫ってきたので方針転換することに。

「じゃあ外部API不要で、シミュレーターだけで動くものを作ろう」ということで、推し活記録アプリを作ることにしました。「今年いくら推しに使ったんだろう...」というのをサッと確認できるアプリ、普通に自分が欲しかったので(笑)

ですが結果的にSwiftUIの基本を学ぶのにちょうどいい題材になりました。

開発環境

完成したアプリ

主な機能はこんな感じです:

  • 📅 カレンダーで日付を選んで推し活を記録
  • 💰 金額を入力してカテゴリ別に集計
  • 📊 年別で合計金額を確認
  • ✅ ライブ、グッズ、CD/DVD、配信などカテゴリ分け
  • 🗑️ スワイプで記録を削除


開発の流れ

1. まずはシンプルなスケジュールアプリから

最初から複雑なものを作ると挫折するので、まずはTODOアプリ的なものからスタートしました。

struct Schedule: Identifiable {
    let id = UUID()
    var title: String
    var date: Date
    var isDone: Bool = false
}

Identifiableプロトコルに準拠させることで、SwiftUIのForEachでそのまま使えるようになります。idプロパティが必須なので、UUID()で一意なIDを自動生成しています。(ただ今回は永続化まで実装していません)

SwiftUIの @State@Binding の基本を押さえつつ、Listの表示・追加・削除を実装しました。


@Stateと@Bindingの使い分け

最初ちょっと混乱したので整理しておくと:

  • @State: そのView自身が所有するデータ。そのViewが所有し、Viewのライフサイクルと紐づくデータ。主に親Viewで宣言する
  • @Binding: 親から渡されたデータへの参照。子Viewで受け取る

例えば、メインのContentViewで @State private var records を持っておいて、追加画面には @Binding var records として渡す。こうすると追加画面で配列に追加したものが、メイン画面にも即座に反映されます。


2. カレンダーUIを自作

標準のDatePickerだと味気ないので、カレンダーUIをAIの手もお借りしながら作成してみました。

private let columns = Array(repeating: GridItem(.flexible()), count: 7)
private let weekdays = ["日", "月", "火", "水", "木", "金", "土"]

まず7列のグリッドを定義して、曜日のヘッダーを表示します。

private var daysInMonth: [Date?] {
    let start = calendar.date(from: calendar.dateComponents([.year, .month], from: currentMonth))!
    let range = calendar.range(of: .day, in: .month, for: start)!
    let firstWeekday = calendar.component(.weekday, from: start) - 1
    
    var days: [Date?] = Array(repeating: nil, count: firstWeekday)
    
    for day in range {
        if let date = calendar.date(byAdding: .day, value: day - 1, to: start) {
            days.append(date)
        }
    }
    
    return days
}

ポイントは firstWeekday の処理です。例えば月の初日が水曜日だったら、日〜火の3マスは空白にする必要があります。nilを入れた配列を先に作っておいて、その後ろに実際の日付を追加していくことで、曜日がズレないカレンダーになります。

ただ今回は祝日等は入れず省略しています🙇‍♂️


日付セルのデザイン

各日付のセルには、いくつかの状態を表示しています:

  • 今日の日付は太字
  • 選択中の日付はピンクの丸
  • 記録がある日は下に小さなピンクの点
struct DayCell: View {
    let date: Date
    let isSelected: Bool
    let isToday: Bool
    let hasRecord: Bool
    
    var body: some View {
        VStack(spacing: 2) {
            Text("\(calendar.component(.day, from: date))")
                .font(.system(size: 16, weight: isToday ? .bold : .regular))
                .foregroundColor(textColor)
                .frame(width: 32, height: 32)
                .background(isSelected ? Color.pink : Color.clear)
                .cornerRadius(16)
            
            Circle()
                .fill(hasRecord ? Color.pink : Color.clear)
                .frame(width: 6, height: 6)
        }
    }
}

地味ですが、土曜は青、日曜は赤にする処理も入れています。カレンダーっぽさが出て満足✨


3. カテゴリをenumで管理

カテゴリごとにアイコンと色を持たせたかったので、enumに computed property を生やしました。

enum RecordCategory: String, CaseIterable {
    case live = "ライブ"
    case goods = "グッズ"
    case cd = "CD/DVD"
    case streaming = "配信"
    case other = "その他"
    
    var icon: String {
        switch self {
        case .live: return "music.mic"
        case .goods: return "bag.fill"
        case .cd: return "opticaldisc"
        case .streaming: return "play.tv"
        case .other: return "star.fill"
        }
    }
    
    var color: Color {
        switch self {
        case .live: return .pink
        case .goods: return .purple
        case .cd: return .orange
        case .streaming: return .blue
        case .other: return .gray
        }
    }
}

CaseIterableに準拠させておくと、RecordCategory.allCasesで全ケースを配列として取得できます。Pickerの選択肢を作るときに便利です。

これで record.category.iconrecord.category.color のようにスッキリ書けます。アイコンはSF Symbolsから選びました。Apple公式で無料で使えるアイコン集なので、iOSアプリ開発に興味がある方は是非!


4. シートで入力画面を表示

新規登録画面は.sheetで表示しています。

.sheet(isPresented: $showAddView) {
    AddRecordView(records: $records, selectedDate: selectedDate)
}

@Environment(\.dismiss)を使うと、シートを閉じる処理が簡単に書けます。(iOS15以降でのみ使用可能)

@Environment(\.dismiss) private var dismiss

Button("保存") {
    // 保存処理
    dismiss()
}

5. 年別集計機能

このままでは年別で集計されておらず、今年と来年も合算されてしまいます。 「来年の予定も入れたいけど、今年と混ぜたくない」という(自分の)要望に対応するため、年でフィルタリングする機能を追加。

@State private var selectedYear: Int = Calendar.current.component(.year, from: Date())

private var recordsForYear: [OshiRecord] {
    records.filter { calendar.component(.year, from: $0.date) == selectedYear }
}

private var totalAmount: Int {
    recordsForYear.map { $0.amount }.reduce(0, +)
}

Calendar.component(.year, from:) で年を取り出して、選択中の年と一致するものだけ抽出しています。

年の切り替えUIはシンプルに左右の矢印ボタンにしました。

HStack {
    Button {
        selectedYear -= 1
    } label: {
        Image(systemName: "chevron.left")
    }
    
    Text("\(String(selectedYear))年")
        .font(.title2)
        .fontWeight(.bold)
    
    Button {
        selectedYear += 1
    } label: {
        Image(systemName: "chevron.right")
    }
}

カテゴリ別の集計も同じ要領で:

private var categoryTotals: [(category: RecordCategory, total: Int, count: Int)] {
    RecordCategory.allCases.compactMap { category in
        let filtered = recordsForYear.filter { $0.category == category }
        if filtered.isEmpty { return nil }
        return (category, filtered.map { $0.amount }.reduce(0, +), filtered.count)
    }
}

compactMapを使って、記録がないカテゴリは表示しないようにしています。


6. TabViewで画面を分ける

カレンダー表示とリスト表示を切り替えられるように、TabViewを導入しました。

TabView(selection: $selectedTab) {
    CalendarTab(records: $records)
        .tabItem {
            Image(systemName: "calendar")
            Text("カレンダー")
        }
        .tag(0)
    
    ListTab(records: $records)
        .tabItem {
            Image(systemName: "list.bullet")
            Text("リスト")
        }
        .tag(1)
}
.tint(.pink)

.tint(.pink)でタブバーの選択色をピンクに。推し活アプリなのでピンク多めです!🎀

@Bindingrecords を共有しているので、どちらのタブで追加・削除しても即座に反映されます。


今回躓いたポイント

✔︎Task という名前は避けた方がいい

最初、モデルを Task という名前にしていたら謎のエラーに。Swift 5.5以降、標準ライブラリに同名のTask型が存在するため、名前が衝突していたようです。

エラーメッセージも Type 'TaskViewModel' does not conform to protocol 'ObservableObject' みたいな、一見関係なさそうなものが出てきて混乱しました。

ScheduleOshiRecord のような名前に変更したらあっさり解決。予約語じゃなくても、標準ライブラリと被る名前は避けた方が無難です。


✔︎Int型の入力フィールド

金額入力で地味に躓いたのが、TextFieldでInt型を扱う方法。

// これだと文字列になる
@State private var amountText: String = ""

// こう書くと直接Int型にバインドできる
@State private var amount: Int = 0
TextField("0", value: $amount, format: .number)
    .keyboardType(.numberPad)

iOS 15以降なら format: .number が使えて便利でした!


今後やりたいこと

今回はデータの永続化まではやっていないので、アプリを閉じると消えます。今回は時間が足らず出来なかったのですが実用するなら以下の機能は欲しい:

  • データ永続化: UserDefaultsかSwiftDataで保存
  • グラフ表示: 月別の推移をChartsで可視化
  • アーティスト別管理: 複数グループ推しの人向けに
  • 写真添付: ライブの思い出を記録に残したい
  • ウィジェット: ホーム画面で今月の合計を表示

翻訳キーボード拡張アプリもいつかリベンジしたいです!


まとめ

今回やりたかった拡張アプリは断念してしまいましたが、SwiftUIの基本を一通り学べたのは良かったです!

  • @State / @Binding / @Environment の使い分け
  • List / ForEach / LazyVGrid でのデータ表示
  • .sheet でのモーダル表示
  • TabView での画面切り替え
  • enumの活用

あたりは今回でだいぶ理解できました。宣言的にUIを書けるので、慣れると楽しいですね。特にPreviewでリアルタイムに確認しながら開発できるのが良かったです!

皆さんも推し活の記録、つけてみませんか?年末に集計すると震えますよ、、(財布が)


最後に

最後まで読んでくださりありがとうございます! 弊社ではエンジニアを募集しております。 ご興味がありましたらカジュアル面談も可能ですので、下記リンクより是非ご応募ください!

iimon採用サイト / Wantedly

明日はHappy Birthday!!🎂 まつださんの記事です。どんな記事を書いてくださるのかとても楽しみです!

参考