🪣

カウシェでの iOS 18 / Xcode 16 対応

2024/12/20に公開5

この記事は、カウシェ Advent Calendar 2024の20日目の記事です。

はじめに

こんにちは、”誰かと一緒に”を楽しむお買い物アプリ「カウシェ」のiOSアプリ開発を担当しているShoMasegiです。

iOS 18やXcode 16が9月にリリースされ、気付けばもう12月。皆さんは iOS 18 / Xcode 16対応 はお済みでしょうか?
私はつい先日、ようやくXcode 16の対応を終わらせました!(とはいえ、Swift 6対応はまだまだこれからですが….)

この記事では、カウシェアプリをiOS 18 / Xcode 16に対応させる際に直面した問題や解決方法をご紹介します。同様のケースに遭遇することは稀かもしれませんが、参考になれば幸いです。

まずは、iOS 18の対応

Xcode 16 Beta が公開され、試しにビルドをしてみたのですが、後述する問題が発生。そのため、iOS 18の対応を優先することにしました。
Xcode 15 でビルドしたアプリを iOS 18 Beta で動かしてみたところ、以下の2つの問題に直面しました。

  1. TabBarが期待しない場所で表示される
  2. NavigationBar内にあるカスタムViewのレイアウトが画面遷移後に崩れる

TabBarの表示問題

こちらを詳しく調べてみると、toolbar(_:for:) modifierの挙動が iOS 17以前とiOS 18で違うことが判明しました。

iOS 17以前 iOS 18

iOS 17以前の挙動
toolbar(.visible, for: .tabBar)を指定した画面から.hiddenを指定した画面へ遷移するとTabBarは非表示になり、逆に.hiddenを指定した画面から.visibleを指定した画面へ遷移するとTabBarが表示される。

iOS 18の挙動
.visibleを指定した画面から.hiddenを指定した画面へ遷移してもTabBarは表示され続け、.hiddenを指定した画面から.visibleを指定した画面へ遷移してもTabBarは非表示のままになる。

検証に使ったコード
/// `TabView`のルート
struct RootTabView: View {
  var body: some View {
    TabView {
      ForEach(["1", "2", "3"], id: \.self) { page in
        NavigationStack {
          UnspecifiedTabBarView()
            .navigationTitle("TabView")
            .navigationBarTitleDisplayMode(.inline)
        }
        .tabItem {
          Image(systemName: "\(page).circle")
          Text("\(page) page")
        }
      }
    }
  }
}

/// `.toolbar(.visible, for: .tabBar)`を指定したView
struct VisibleTabBarView: View {
  var body: some View {
    VStack(spacing: 12) {
      Text("Visible")
        .font(.largeTitle.bold())

      NavigationLink("Navigate to Hidden") {
        HiddenTabBarView()
      }
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .toolbar(.visible, for: .tabBar)
  }
}

/// `.toolbar(.hidden, for: .tabBar)`を指定したView
struct HiddenTabBarView: View {
  var body: some View {
    VStack(spacing: 12) {
      Text("Hidden")
        .font(.largeTitle.bold())

      NavigationLink("Navigate to Visible") {
        VisibleTabBarView()
      }
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .toolbar(.hidden, for: .tabBar)
  }
}

/// `.toolbar(_:for:)`を指定しないView
struct UnspecifiedTabBarView: View {
  var body: some View {
    VStack(spacing: 12) {
      Text("Unspecified")
        .font(.largeTitle.bold())

      NavigationLink("Navigate to Visible") {
        VisibleTabBarView()
      }

      NavigationLink("Navigate to Hidden") {
        HiddenTabBarView()
      }
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
  }
}

この挙動はiOS 18で追加されたtoolbarVisibility(_:for:)を使って試しても変わりませんでした。

解決方法

カウシェではTabViewのルート画面以外でTabBarの表示/非表示を切り替える必要がなかったため、以下の対応を行うことで回避できました。

  • ルート画面でtoolbar(.visible, for: .tabBar)の指定を無くす
  • 非表示にしたい画面でtoolbar(.hidden, for: .tabBar)を指定する

iOS 18以降ではこの挙動を正として画面や遷移の設計をしていく必要がありそうです。

こちらはまだ未解決で、現状のアプリでも再現するものです。

戻るボタンがある画面でtoolbar(content:)を使ってNavigationBarにカスタムViewを追加すると、その画面の遷移先から戻ってきて再表示する際にカスタムViewのレイアウトが崩れてしまいます。

iOS 17以前 iOS 18

Root View → Search View → Result View → Search View と遷移して、Search Viewに戻った時にNavigationBarに表示しているカスタムViewが縮んでいるのが分かると思います。

再描画する際に、戻るボタンの横幅を優先的に取るため、.principalに配置したToolBarItemの横幅がコンテンツの最小横幅に狭められていると推察しています。

検証に使ったコード
/// トップ画面(`NavigationBar`にViewを入れてない画面)
struct SearchRootView: View {
  var body: some View {
    NavigationStack {
      VStack(spacing: 12) {
        Text("Root View")
          .font(.largeTitle.bold())

        NavigationLink("Navigate to SearchView") {
          SearchContentView()
        }
      }
      .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
  }
}

/// 検索画面(`NavigationBar`にViewを入れている画面)
struct SearchContentView: View {
  var body: some View {
    VStack(spacing: 12) {
      Text("Search View")
        .font(.largeTitle.bold())

      NavigationLink("Navigate to ResultView") {
        // 結果画面
        Text("Result View")
          .font(.largeTitle.bold())
          .frame(maxWidth: .infinity, maxHeight: .infinity)
      }
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .navigationBarTitleDisplayMode(.inline)
    .toolbar {
      ToolbarItem(placement: .principal) {
        SearchHeaderView()
      }
    }
  }
}

/// `NavigationBar`に表示するView(検索バーとか)
struct SearchHeaderView: View {
  var body: some View {
    HStack(spacing: 8) {
      // 🔍
      Image(systemName: "magnifyingglass")
        .resizable()
        .foregroundColor(.gray)
        .frame(width: 20, height: 20)

      // 検索様のTextFieldとか
      Text("検索キーワード")
        .font(.subheadline)
        .foregroundColor(.gray)
        .padding(.vertical, 2)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
    }
    .padding(.vertical, 6)
    .padding(.horizontal, 12)
    .background(.gray.opacity(0.4))
    .frame(height: 32)
    .frame(maxWidth: .infinity)
    .cornerRadius(16)
  }
}

他社のアプリでも類似挙動を確認しており、SwiftUIの不具合なのか実装の問題なのか判断が出来ず、カウシェでは一旦対応を後回しにしています。次のiOSバージョンで直らない場合はNavigationBarごとカスタムViewとして作ることも考えています。

もし良い対処法をご存知の方は、コメントいただけると幸いです。

[追記]
くろのすさんに教えていただいた方法で修正できました!
本当にありがとうございます!

https://zenn.dev/link/comments/70b7f0d7edfe7d

そして、Xcode 16対応へ

前の章で書いた通り、Xcode 16でビルドした際に簡単に直すことが難しそうな問題にぶつかったため、一度対応を後回しにしました。それが、PageView内にあるListに配置したカルーセルのジェスチャーの誤発火です。

他にもCIでのビルド調整もあったので順に紹介していきます。

PageView内にあるList内カルーセルのジェスチャー問題

結論から言うと、Xcode 16でビルドしてiOS 18で実行する場合に、List内で発生したスクロールとListの下のViewでsimultaneousGesture(_:)で登録した横方向のDragGestureが一緒に反応するようになりました。

simultaneousGestureを使っているんだから当たり前でしょう?」と指摘されるとその通りなのですが、Xcode 15でビルドしたアプリの挙動と Xcode 16でビルドしたiOS 18でのアプリの挙動が異なっていたので調査に苦労しました。

iOS 17以前 iOS 18

カウシェアプリは下のようなデザインになっています。
メインの画面とも言えるホームタブでは、上タブを採用しており、横スクロールでタブを切り替えられるようになっています。また、そのタブ内には無限カルーセルを配置しており、セール中の商品を表示しています。

実装としては、上タブとカルーセルはScrollViewやTabViewを使わずに、DragGestureを使って実装しています。
このDragGestureを設定する際に、上タブとカルーセルの両方でsimultaneousGesture(_:)を使って設定しており、iOS 17以前では期待していた通りに動いていたものの、iOS 18でジェスチャーが誤爆するようになっていました。

最低限の実装で再現したもの
iOS 17以前 iOS 18

解決方法

修正方法は至極当然のことで、適したViewに適した方法でGestureを設定するだけです。

今回は、優先して反応してほしいカルーセルにはhighPriorityGesture(_:)で、上タブのページングにはListが一緒に反応しないようにgesture(_:)で設定することで解決しました。SwiftUIのジェスチャーは難しいですね。
(iOS 17からScrollTargetBehavior APIが使えるようになり、カルーセルがScrollViewで手軽に実装出来るようになったので、今後はTabView と ScrollViewを使った実装が良いかもしれません。)

xcodebuildで発生したエラー

カウシェでは、コード変更がPushされるたびに CI上でテストを実行して動作を担保しています。CIには Bitrise を、テスト実行は Fastlane の scan action を使用しています。
Xcode 16に移行する際もCI上でのStackをXcode 16に上げてテストを実行したのですが、そこで以下のエラーが発生しました。
(カウシェでは各モジュールをプロジェクトを分けています)

[05:25:31]: ▸ ❌ /Users/vagrant/git/Projects/KaucheMockKit/KaucheMockKit.xcodeproj: No signing certificate "Mac Development" found: No "Mac Development" signing certificate matching team ID "xxxx" with a private key was found. (in target 'KaucheMockKitTests' from project 'KaucheMockKit')
[05:25:31]: ▸ ❌ /Users/vagrant/git/Projects/KaucheAPIKit/KaucheAPIKit.xcodeproj: No signing certificate "Mac Development" found: No "Mac Development" signing certificate matching team ID "xxxx" with a private key was found. (in target 'KaucheAPIKitTests' from project 'KaucheAPIKit')
[05:25:31]: ▸ ❌ /Users/vagrant/git/Projects/KaucheDesignKit/KaucheDesignKit.xcodeproj: No signing certificate "Mac Development" found: No "Mac Development" signing certificate matching team ID "xxxx" with a private key was found. (in target 'KaucheDesignKitTests' from project 'KaucheDesignKit')
[05:25:31]: ▸ ❌ /Users/vagrant/git/Projects/Kauche/Kauche.xcodeproj: No profile for team 'xxxx' matching 'match Development xxxx' found: Xcode couldn't find any provisioning profiles matching 'xxxx'. Install the profile (by dragging and dropping it onto Xcode's dock item) or select a different one in the Signing & Capabilities tab of the target editor. (in target 'NotificationService' from project 'Kauche')
[05:25:31]: ▸ ❌ /Users/vagrant/git/Projects/Kauche/Kauche.xcodeproj: No profile for team 'xxxx' matching 'match Development xxxx' found: Xcode couldn't find any provisioning profiles matching 'xxxx'. Install the profile (by dragging and dropping it onto Xcode's dock item) or select a different one in the Signing & Capabilities tab of the target editor. (in target 'Kauche' from project 'Kauche')
[05:25:31]: ▸ ** BUILD FAILED **
[05:25:31]: ▸ The following build commands failed:
[05:25:31]: ▸ 	Building workspace Kauche with scheme Tests

「何故か macOS でビルドされている?」と思いログを確認していると、Xcode 15では xcodebuild の際に destination を指定しなくても自動で iOS を指定してビルドしてくれていたのが、Xcode 16では無くなっているようでした。

それらしき記述がXcode 16の Release notes にかかれていました。

  • Fixed an issue where xcodebuild silently selects the first compatible run destination when it fails to match the destination specifier provided by the user. (112043381)

https://developer.apple.com/documentation/xcode-release-notes/xcode-16-release-notes#xcodebuild

解決方法

sacn action に今まで指定していなかった destination を入れることで解決することが出来ました。

// before
scan(scheme: scheme)

// after
scan(scheme: scheme, devices: ["iPhone 16"])

これにてXcode 16移行を完了することが出来ました🎉

まとめ

iOS 18 / Xcode 16の対応は、SwiftUIの挙動差分やxcodebuildの改修等でかなり手こずってしまいました。

ただ、Xcode 16には Swift 6 や Swift Testing、 Preview改善、swift-formatの内蔵、診断周りの機能などなど、数多くの改善が入っています。どれも開発効率を上げてくれる機能ばかりなので、バリバリ使い倒してよりよいアプリを提供出来るように頑張っていこうと思います!

カウシェ Tech Blog

Discussion

くろのすくろのす

NavigationBar内のカスタムViewのレイアウトが崩れる問題の部分のサンプルコードが、おそらく異なるものになっています。修正していただければ、情報提供できるかもしれません...

Sho MasegiSho Masegi

指摘いただきありがとうございます!
今修正したので、見ていただけるとありがたいです🙏
とても助かります!

くろのすくろのす

おそらくこれで目的の動作が実現できると思います。.frame(idealWidth: )を設定しました。

修正版

struct SearchHeaderView: View {
  var body: some View {
    HStack(spacing: 8) {
      Image(systemName: "magnifyingglass")
        .resizable()
        .foregroundColor(.gray)
        .frame(width: 20, height: 20)

      // 検索様のTextFieldとか
      Text("検索キーワード")
        .font(.subheadline)
        .foregroundColor(.gray)
        .padding(.vertical, 2)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
    }
    .padding(.vertical, 6)
    .padding(.horizontal, 12)
    .background(.gray.opacity(0.4))
    .frame(height: 32)
    .frame(idealWidth: 10000, maxWidth: .infinity)
    .cornerRadius(16)
  }
}
Sho MasegiSho Masegi

ありがとうございます!!!
直りました!!!🙇‍♂️

くろのすくろのす

この方法で手元の環境(iOS18.2)では解決しておりますが、いかがでしょうか?他にアプローチはあると思いますが...