🕌

Claude Codeを利用してDroidKaigi2025公式iOSアプリにContributionをしてみた話

に公開

1. はじめに

皆様お疲れ様です。Swift AdventCalendarの11日目を担当させて頂きます、fumiyasac(Fumiya Sakai)と申します。何卒よろしくお願い致します。

今回の記事では、「Claude Codeを使いながらOSSアプリのUIを段階的にリファインしていったプロセス」 を、実務的な観点を交えながらまとめます。

題材は「DroidKaigi2025の公式アプリ」になります。

私はセッション検索画面の改善PRを担当し、

  1. SearchBarのデザイン改善
  2. 絞り込みUIをChip → UIMenuに刷新
  3. 言語フィルタのロジック修正

といった UI/ロジック両面の改修を行いました。

改修前のiOSアプリのセッション検索画面は、動作はしているがAndroidアプリと比較すると画面デザインが乖離しており、GitHubで既にIssueが起票されていたので、iOSアプリの改善に着手することにしました。

また、今回は「AIを活用した開発手法であるVibe Coding」によって、この課題に挑戦しました。

今回の内容は、その実体験と学びを「OSS貢献+実用的なUI改修の事例」として共有するものです。

該当のIssue & Pull Request:

⭐️ Issue:

https://github.com/DroidKaigi/conference-app-2025/issues/142

⭐️ Pull Request:

https://github.com/DroidKaigi/conference-app-2025/pull/487

🌾 補足資料に関して:

今回の内容につきましては、kanagawa.swift #2にて登壇の際に利用した資料を下記のリンクにて共有しておりますので、こちらも是非ご活用頂けますと幸いです。

https://speakerdeck.com/fumiyasac0921/claude-codewoli-yong-sitedroidkaigi2025gong-shi-iosapurinicontributionwositemitahua

2. iOS版のFigmaがない状態でまず始めたこと

改修前のiOS版のセッション検索画面UIは3種類で横1列のChip形式でセッション絞り込みの項目を表示する形になっていました。機能としては概ね問題なさそうに思えましたが、Android版はセッション絞り込みの項目をドロップダウン方式で表示する形式にしていたので、iOS版とAndroid版では画面表示や体験に差分がある状態でした。

Android版にはFigmaデータがありましたが、iOS版はありませんでした。仕様がないときは「意図」を読み取りに行くしかないと考えて、まず行ったのは、

  • Android側をビルドしてUI/挙動を確認する
  • 公式Figma(Android版)で意図を読み解く
  • iOSの既存実装との差分をマッピングする

という “現状把握と差分分析” でした。

“iOS側に公式デザインがないから無理”と諦めるのではなく、Android実装 + Figmaのスクリーンショットを“橋渡し”にしながら、まず現状を自分の手で 「見る/触る/理解する」 所から始めるとより理解が深まり、改修をする際の手助けになると思います。

【Android版のFigma】

Android版のFigma

【iOS版のBefore/Afterの画面想定】

Before After
修正前の検索エリアデザイン 修正後の検索エリアデザイン

3. 内部アーキテクチャに関して

iOS版のセッション検索画面UIが対象となるので、アーキテクチャを理解しながら改修範囲を調査しました。DroidKaigi2025の公式アプリは、KMP(Kotlin Multiplatform)をベースにして作成されています。該当画面のiOS版における改修範囲は “UI層&Presentation層の2つ” になります。

実際にコーディングをする際にはAIを補助ツールとして活用する(Vibe Coding)ことになりますが、事前に改修に影響を及ぼす範囲を調査しておくと、より精度が高いプロンプトを作成する上でも役に立つと思います。

🌾 Bookmark処理におけるデータの流れを整理した参考資料:

※Unidirectionlなデータフローを実現する様なアーキテクチャを採用しています。

Unidirectional Architecture in DroidKaigi 2025

4. AI(Claude Code)の活用と段階的な改善

今回の改修ではVibe Codingを前提として進めていく方針を採用しましたが、いきなり大きく作り直すと改修範囲が想定以上に大きくなったり、レビュアーの負担になると考えました。

そこで、下記の様な3つの小さな改善ステップに分割して進める事にしました。

  1. SearchBarのデザインに対する変更
  2. Chipでの絞り込み検索表示をUIMenu表示にする変更
  3. 登録データ起因部分の調整対応

iOS版とAndroid版のセッション絞り込み検索部分の体験を合わせる

ロジック部分の抜本的な変更は不要かもしれないが、画面構成も大きく変化する事が予想されました。そして、Vibe Codingを活用する事によって工数や時間削減につながる期待もありました。

この“小さな改善の積み重ね”はAIと非常に相性が良く、Claude Codeのプロンプトには 「改修範囲を明確に区切る」 ことを徹底しました。

この粒度を守る事によって、

  • 思わぬ副作用の発生を防止する
  • Claude Codeが必要以上に構造を変更しない
  • Pull Requestがステップ毎にレビューし易くなる

というメリットもあった様に思います。

5. Step1: SearchBarのデザインに対する変更

改修前の状態では、NavigationBar部分の配色も、想定するデザインと異なっていたため下記2点の変更がまずは必要でした。

  • SearchBar表示のデザイン変更
  • NavigationBarの配色変更

この程度の修正であれば、手作業でも事足りる粒度ではありますが、調査をしながらデザインを調整していく作業は想像以上に時間を要する事が多いため、この点では工数を削減する事ができた様に思います。

🗒️ Claude Codeに指示をしたプロンプト例:

提示したFigmaデザインに合う様に、アプリケーションのデザインを変更してください。

- 改修対象はSearchScreen.swiftになります。
- 他のコードにはできるだけ手を入れないでください。

※Android版のFigmaのスクリーンショットも一緒に添えています。

(注意)こちらのプロンプトは出力結果を保証するものではありません。

🤖 出力されたコードの差分例:

https://github.com/DroidKaigi/conference-app-2025/pull/487/commits/fa439a3caf953fb17a1f652e95e538fa23e42780

6. Step2: Chipでの絞り込み検索表示をUIMenu表示にする変更

次に、改修前の状態では、Chipでのセッション絞り込み検索表示をする部分を、Android版での振る舞いや体験を参考にしてUIMenu表示に変更する対応を進めていきます。

  • 3種類横1列で並んでいた自作Chip表示部分をUIMenu表示を利用した項目選択表示への変更

画面構成が抜本的に変更される可能性が高いと考えたので、改修範囲の制限や想定する検索の形をプロンプトで表現する必要がありました。

もし、手動でこの画面を作り替えると仮定すると、2〜3日程度は必要だったと予想していました。

実際にClaude Codeで出力した差分を確認してみると、改修範囲はSearchScreen.swiftの範囲内に収まりましたが、大きな差分が生じました。今回は結果的には大きな変更を伴う改修になりましたが、Vibe Codingを活用する事によって大幅な工数削減を図る事ができました。

🗒️ Claude Codeに指示をしたプロンプト例:

SearchScreenのCategroryFilterViewのデザインをFigmaに合わせてください。

- Day・Category・Languageの3つのFilterViewをUIMenuを利用して絞り込み検索ができる形に修正してください。
- 改修対象はSearchScreen.swiftになります。
- 他のコードにはできるだけ手を入れないでください。

※Android版のFigmaのスクリーンショットも一緒に添えています。

(注意)こちらのプロンプトは出力結果を保証するものではありません。

🤖 出力されたコードの差分例:

https://github.com/DroidKaigi/conference-app-2025/pull/487/commits/fa439a3caf953fb17a1f652e95e538fa23e42780

🍎 SearchScreen.swift全体のコード:

SearchScreen.swift
public struct SearchScreen: View {

    // ...(途中省略)...

    public var body: some View {
        VStack(spacing: 0) {
            // 👉 ① SearchBar要素
            searchBar
            // 👉 ② UIMenu製の絞り込み項目 + 検索結果表示
            filterAndResultsScrollView
        }
        .background(AssetColors.surface.swiftUIColor)
        .navigationTitle(String(localized: "Search", bundle: .module))
        #if os(iOS)
            .navigationBarTitleDisplayMode(.large)
        #endif
        .onAppear {
            presenter.loadInitial()
            isSearchFieldFocused = true
        }
    }

    // ...(途中省略)...

    private var filterAndResultsScrollView: some View {
        VStack {
            // 👉 ③ UIMenu製の絞り込み項目
            filterMenusSection
            // 👉 ④ 検索結果表示
            ScrollView {
                VStack(alignment: .leading, spacing: 20) {
                    searchResultsSection
                }
                .padding(.top, 0)
            }
        }
        .frame(maxHeight: .infinity)
    }

    private var filterMenusSection: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            // 👉 ⑤ 曜日・カテゴリー・言語での絞り込みメニュー表示
            HStack(spacing: 12) {
                dayFilterMenu
                categoryFilterMenu
                languageFilterMenu
            }
            .padding(.horizontal, 16)
        }
    }

    // ...(途中省略)...

    private var categoryFilterMenu: some View {
        Menu {
            Button("All") {
                presenter.selectedCategory = nil
            }
            if let timetable = presenter.timetable.timetable {
                let uniqueCategories = Set(timetable.timetableItems.map { $0.category })
                let categories = Array(uniqueCategories).sorted { $0.id < $1.id }
                ForEach(categories) { category in
                    Button(category.title.jaTitle) {
                        presenter.selectedCategory = category
                    }
                }
            }
        } label: {
            HStack(spacing: 8) {
                Text("カテゴリ")
                    .font(.subheadline)
                    .foregroundStyle(AssetColors.onSurface.swiftUIColor)

                Text(categoryFilterDisplayText)
                    .font(.subheadline)
                    .foregroundStyle(AssetColors.primary.swiftUIColor)

                Image(systemName: "chevron.down")
                    .font(.caption)
                    .foregroundStyle(AssetColors.onSurfaceVariant.swiftUIColor)
            }
            .padding(.horizontal, 12)
            .padding(.vertical, 8)
            .background(
                RoundedRectangle(cornerRadius: 8)
                    .fill(AssetColors.surfaceVariant.swiftUIColor)
                    .stroke(AssetColors.outline.swiftUIColor.opacity(0.2), lineWidth: 1)
            )
        }
    }

    private var categoryFilterDisplayText: String {
        presenter.selectedCategory?.title.jaTitle ?? "全て"
    }

    private var languageFilterMenu: some View {
        Menu {
            Button("All") {
                presenter.selectedLanguage = nil
            }
            Button("Japanese") {
                toggleLanguage("JA")
            }
            Button("English") {
                toggleLanguage("EN")
            }
            Button("Mixed") {
                toggleLanguage("MIXED")
            }
        } label: {
            HStack(spacing: 8) {
                Text("言語")
                    .font(.subheadline)
                    .foregroundStyle(AssetColors.onSurface.swiftUIColor)

                Text(languageFilterDisplayText)
                    .font(.subheadline)
                    .foregroundStyle(AssetColors.primary.swiftUIColor)

                Image(systemName: "chevron.down")
                    .font(.caption)
                    .foregroundStyle(AssetColors.onSurfaceVariant.swiftUIColor)
            }
            .padding(.horizontal, 12)
            .padding(.vertical, 8)
            .background(
                RoundedRectangle(cornerRadius: 8)
                    .fill(AssetColors.surfaceVariant.swiftUIColor)
                    .stroke(AssetColors.outline.swiftUIColor.opacity(0.2), lineWidth: 1)
            )
        }
    }

    private var languageFilterDisplayText: String {
        guard let language = presenter.selectedLanguage else { return "全て" }
        switch language.langOfSpeaker {
        case "JA":
            return "Japanese"
        case "EN":
            return "English"
        case "MIXED":
            return "Mixed"
        default:
            return "全て"
        }
    }

    private func toggleLanguage(_ lang: String) {
        if presenter.selectedLanguage?.langOfSpeaker == lang {
            presenter.selectedLanguage = nil
        } else {
            presenter.selectedLanguage = TimetableLanguage(
                langOfSpeaker: lang, isInterpretationTarget: false)
        }
    }

    private var dayFilterMenu: some View {
        Menu {
            Button("All") {
                presenter.selectedDay = nil
            }
            Button("9/11") {
                presenter.selectedDay = .conferenceDay1
            }
            Button("9/12") {
                presenter.selectedDay = .conferenceDay2
            }
        } label: {
            HStack(spacing: 8) {
                Text("開催日")
                    .font(.subheadline)
                    .foregroundStyle(AssetColors.onSurface.swiftUIColor)

                Text(dayFilterDisplayText)
                    .font(.subheadline)
                    .foregroundStyle(AssetColors.primary.swiftUIColor)

                Image(systemName: "chevron.down")
                    .font(.caption)
                    .foregroundStyle(AssetColors.onSurfaceVariant.swiftUIColor)
            }
            .padding(.horizontal, 12)
            .padding(.vertical, 8)
            .background(
                RoundedRectangle(cornerRadius: 8)
                    .fill(AssetColors.surfaceVariant.swiftUIColor)
                    .stroke(AssetColors.outline.swiftUIColor.opacity(0.2), lineWidth: 1)
            )
        }
    }

    private var dayFilterDisplayText: String {
        switch presenter.selectedDay {
        case .conferenceDay1:
            return "9/11"
        case .conferenceDay2:
            return "9/12"
        case nil:
            return "全て"
        default:
            return "全て"
        }
    }
}

7. Step3: 登録データ起因部分の調整対応

こちらは、改修過程の中で発見した不具合になりますが、セッション絞り込み表示の中で「言語」による絞り込み検索がうまくいっていない事を発見したため、この点についてもVibe Codingを活用して修正する事にしました。直接的な原因は「登録されているデータの表記揺れ」でしたが、登録されるデータを修正するのではなく、クライアント側でデータ内の表記揺れ対応をする事にしました。

  • クライアント側での絞り込み処理を調整する対応

この様な不具合の場合にはまずは データが問題なのか?あるいは、処理の問題なのか? を切り分ける必要がありますが、一見するだけでは原因を見抜くのは難しい類のものだと思います。今回の修正についても、実際に何度か同じプロンプトで試しては捨ててを繰り返して原因の究明と調整対応の両方を繰り返しました。今回は結果的にはクライアント側の処理を調整する対応に落ち着きましたが、こちらもVibe Codingを活用する事によって大幅な工数削減と原因究明の早期化を図る事ができました。

🗒️ Claude Codeに指示をしたプロンプト例:

SearchScreenで言語による絞り込みがうまくいっていない様です。こちらの修正をお願いします。

- 改修対象はSearchPresenter.swiftになります。
- 他のコードにはできるだけ手を入れないでください。

(注意)こちらのプロンプトは出力結果を保証するものではありません。

🤖 出力されたコードの差分例:

https://github.com/DroidKaigi/conference-app-2025/pull/487/commits/8f793e454928912fdb7542a702d0b5ac6da34989

🍎 SearchPresenter.swift全体のコード:

SearchPresenter.swift
@MainActor
@Observable
final class SearchPresenter {
    let timetable = TimetableProvider()

    var searchWord: String = ""
    var selectedDay: DroidKaigi2025Day?
    var selectedCategory: TimetableCategory?
    var selectedSessionType: TimetableSessionType?
    var selectedLanguage: TimetableLanguage?

    var filteredTimetableItems: [TimetableItemWithFavorite] {
        let allItems = timetable.dayTimetable.values
            .flatMap { $0 }
            .flatMap { $0.items }

        return allItems.filter { item in
            // Search word filter
            if !searchWord.isEmpty {
                let searchLower = searchWord.lowercased()
                let matchesTitle =
                    item.timetableItem.title.jaTitle.lowercased().contains(searchLower)
                    || item.timetableItem.title.enTitle.lowercased().contains(searchLower)
                let matchesDescription =
                    if let session = item.timetableItem as? TimetableItemSession {
                        session.description.jaTitle.lowercased().contains(searchLower)
                            || session.description.enTitle.lowercased().contains(searchLower)
                    } else {
                        false
                    }

                let matchesSpeaker = item.timetableItem.speakers.contains { speaker in
                    speaker.name.lowercased().contains(searchLower)
                }

                if !matchesTitle && !matchesDescription && !matchesSpeaker {
                    return false
                }
            }

            // Day filter
            if let day = selectedDay {
                if item.timetableItem.day != day {
                    return false
                }
            }

            // Category filter
            if let category = selectedCategory {
                if item.timetableItem.category != category {
                    return false
                }
            }

            // Session type filter
            if let sessionType = selectedSessionType {
                if item.timetableItem.sessionType != sessionType {
                    return false
                }
            }

            // Language filter
            if let language = selectedLanguage {
                let itemLang = item.timetableItem.language.langOfSpeaker.uppercased()
                let selectedLang = language.langOfSpeaker.uppercased()

                // 👉 言語の表記揺れ対応をする処理を適用する
                let normalizedItemLang = normalizeLanguageCode(itemLang)
                let normalizedSelectedLang = normalizeLanguageCode(selectedLang)

                if normalizedItemLang != normalizedSelectedLang {
                    return false
                }
            }

            return true
        }
    }

    // ...(途中省略)...

    // 👉 表記揺れ対応を処理するために追加した処理
    private func normalizeLanguageCode(_ langCode: String) -> String {
        let upperCode = langCode.uppercased()

        switch upperCode {
        case "JA", "JAPANESE", "JP":
            return "JA"
        case "EN", "ENGLISH", "ENG":
            return "EN"
        case "MIXED":
            return "MIXED"
        default:
            // Handle codes like "ENGLISH" or "JAPANESE" from actual data
            if upperCode.hasPrefix("JA") || upperCode.contains("JAPANESE") {
                return "JA"
            } else if upperCode.hasPrefix("EN") || upperCode.contains("ENGLISH") {
                return "EN"
            } else if upperCode.contains("MIXED") {
                return "MIXED"
            }
            return upperCode
        }
    }
}

8. まとめ

今回のDroidKaigi2025公式アプリiOS版へのContributionを通じて、AI(Claude Codeを活用した段階的なUI改修が現実的に機能することを確認する事ができました。一方で、AIの出力を鵜呑みにせず、既存コードベースやプロダクト要件に合わせて微調整を重ねる“人間側の工夫”も必要であると実感しました。

✅ メリット: 「段階的改善 × AI」で複雑なUI改修が可能になった

  • FigmaデザインやAndroid実装を基点に、iOS側の現状を正確に把握できた
    • iOSにデザインデータがない状態でも、Android実機での動作確認やFigmaのキャプチャを軸に「どこをどう変えるべきか」を掴めた。
    • まず自分で深く理解するステップが、後続のAI活用において大きな効率化につながった。
  • 大規模改修を小さなステップに分割し、安全に進められた
    • SearchBar → 絞り込み UI → 言語フィルタロジックという3Stepに分解したことで、影響範囲を限定しつつ確実に改善を積み重ねられた。
  • Claude Code の支援により、実装速度と検証サイクルが大きく向上した
    • ゼロからUIを作り直すような大規模変更でも、AIの力を借りれば短時間で「ある程度動くプロトタイプ」を作成できる。
    • その後の微調整に集中でき、実装と検証のサイクルが高速化した。

⚠️ 限界: AI生成コードは “そのままでは使えない”

  • AIの出力は正しく見えても、既存ロジックと完全には整合しない
    • 特に絞り込みロジックやデータ構造は、人間が最終的に整合性を確認しながら調整する必要があった。
  • プロンプトの粒度によって結果が大きく変わる
    • 「変更範囲を限定する」「破壊的変更をしない」「既存 UI のスタイルを維持する」など、
      意図を明確に言語化したプロンプト設計が不可欠だった。
  • Figma・Android実装・iOSの3者間で完全一致を保つには追加調整が必要
    • デザイン差分やOS固有のUI制約はAIだけでは吸収できない部分も多い。

AIの力を借りることで、複雑な画面改修であっても実装にかかる負荷は大きく軽減される一方で、生成されたコードをそのまま使うのではなく、既存のコードベースやプロダクト文脈に合わせて丁寧に調整する作業は不可欠であると感じた次第です。

Discussion