STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

今さらながら Liquid Glass 対応を話します

気づけばもう iOS 27 が出る今になって、ようやく iOS 26 についての記事を公開します。STORES ブランドアプリの対応をする時、iOS 26 の不具合がどれもこれもかなり厄介で、本当に OS 側の問題なのか、そもそも自分がちゃんと直せているのかすら確信が持てないことが多かったからです。今やっとリリースして安定したため、その経験を共有します。

UIBarButtonItem

もしあなたのアプリが SwiftUI ではなく UIKit で作られていて、かつ customView で UIBarButtonItem を組んでいるなら、あたりです。ここでは簡単なサンプルを用意して、何が起きるのか見てみましょう。

これは iOS 18 のシミュレータで動かしたデモです。以下のコードは重要な部分だけ残して大幅に省略しています。

// サイズを渡す
override init(frame: CGRect) {
    imageFrame = frame
    super.init(frame: frame)
    setup()
}
    
func setup() {
    // (この部分は省略)
    translatesAutoresizingMaskIntoConstraints = false
    imageView.translatesAutoresizingMaskIntoConstraints = false
    imageView.contentMode = .scaleAspectFit
    addSubview(imageView)
    NSLayoutConstraint.activate([
        imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
        imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
        imageView.topAnchor.constraint(equalTo: topAnchor),
        imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
        imageView.widthAnchor.constraint(equalToConstant: imageFrame.width),
        imageView.heightAnchor.constraint(equalToConstant: imageFrame.height),
    ])
    // (この部分は省略)
}

iOS 18 で問題なさそうに見えますが、iOS 26 では様子が変わってしまいます。

ここでは 2 つの問題があります。まず、画像が長方形になってしまっていること、そして、色がすべて消えてしまっていることです。これらを一つずつ解決していく必要があります。

サイズの問題

init で渡したサイズがどうやら書き換えられているようです。試しに 2 行足してみましょう。

widthAnchor.constraint(equalToConstant: imageFrame.width),
heightAnchor.constraint(equalToConstant: imageFrame.height),

効果はなさそうです。

実は、画像を外部のサイズに依存させないようなレイアウトが必要です。

NSLayoutConstraint.activate([
    widthAnchor.constraint(equalToConstant: imageFrame.width),
    heightAnchor.constraint(equalToConstant: imageFrame.height),
    imageView.centerXAnchor.constraint(equalTo: centerXAnchor),
    imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
    imageView.widthAnchor.constraint(equalToConstant: imageFrame.width),
    imageView.heightAnchor.constraint(equalToConstant: imageFrame.height),
])

これでだいぶ良くなりました。

色の問題

次に解決すべきものは色の問題です。STORES ブランドアプリでは、見た目を調整するため、バッジは CALayer で描いています。ご覧のとおり、色が丸ごと消えました。ただし、画像なら色は消えません。これを確かめるのは簡単で、赤い点を画像に差し替えればよいです。

結局なぜこんな挙動になるのかは最後まで分かりませんでした。しかし、色々試したおかげで根本的な解決策にたどり着きました。それは、それは、SwiftUI で書き直すことです。。。

根本的な解決

customView を SwiftUI で描いたものに変え、UIHostingController でラップするだけで、すべての問題が解決し、レイアウトも正しくなります。

さらに、バッジは新しい API で外側に描くように変更できます。これでボタンはほぼ完璧になります。

// iOS26+
item.badge = .string("3")
item.badge?.backgroundColor = .systemRed
item.badge?.foregroundColor = .white

まだ終わりません

これで終わりだと思いましたか?まだです。iOS 26 から提供されたこのバッジ API には、致命的な問題が一つあります。それは、内容が更新されない可能性があるという点です。色々実験して、一番簡単な解決策は、バッジを変更した後に UIBarButtonItem の内容を一度置き換えることです。たとえば下記のように、いったん外して付け直すと、すぐ効きます。

item.badge = .string("3")
item.customView = nil
item.customView = customView

もう一つの問題として、ボタンを rightBarButtonItems で設定している場合、ボタンの並び順が逆になることがあります。これはどうやら読み込みのタイミングに関係しているようで、DispatchQueue.main.async を一度挟むだけで手元では解決できました。とはいえ、新しい trailingItemGroups を使うことをおすすめします。

self.navigationItem.trailingItemGroups = [
    UIBarButtonItemGroup(barButtonItems: [rightBarButtonItems1], representativeItem: nil),
    UIBarButtonItemGroup(barButtonItems: [rightBarButtonItems2], representativeItem: nil)
]

Apple が予想しないデザインをするなら

STORES ブランドアプリでは、一部のアプリの NavigationBar に背景色を設定しています。こうすると、落とし穴があります。UIBarButtonItem は NavigationBar の背景色ではなく、その下にある ScrollView のコンテンツに応じてライト/ダークモードを切り替えます。ところが、色のほうは NavigationBar に設定した背景色に影響されます。そのため、ライトのときは見た目が問題なくても:

ダークモードのときは恐ろしい見た目になります:

根本的な解決とは言えないですが、ボタン全体を白に固定することで、望ましくないパターンを回避できます。

scanItem.style = .prominent
scanItem.tintColor = .white

さらにこの状況では、非常に細かい問題がもう一つあります。アイコンが黒でない場合、UIKit の CustomView と SwiftUI の CustomView とでレンダリングされる色が変わります。UIKit の CustomView は下地の背景色の影響を受けて少し黒っぽく見え、CustomView を使わない場合と同じ結果になります。一方 SwiftUI のほうは下地の背景色の影響を受けず、本来の色に近くなります。

UITabBarController

TabBar 自身の問題

仮にあなたのアプリにログイン用のシートがあり、ユーザーがログインした後に TabBar の内容を作り直す必要があるとします。そこで、ログイン後に UITabBarController を削除して作り直したとしましょう。

let tb = UITabBarController()
tabBarVC?.willMove(toParent: nil)
tabBarVC?.view.removeFromSuperview()
tabBarVC?.removeFromParent()
tb.viewControllers = [
    makeTab(title: "Home", systemImage: "house.fill"),
    makeTab(title: "Search", systemImage: "magnifyingglass"),
    makeTab(title: "Profile", systemImage: "person.crop.circle.fill"),
]

addChild(tb)
tb.view.frame = view.bounds
tb.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(tb.view)
tb.didMove(toParent: self)

self.dismiss(animated: true)

さて、あたりです。こんなバグが出ます。

これ普通?では、指で押さえてみましょう。おお、カッコいいバグですね。

解決方法も簡単で、dismiss のアニメーションが再生し終わってから差し替えればよいです。こちらの問題は同じチームの方が Feedback に提出したので、こちらから確認できます:FB22597916

TabBar が他に影響する問題

もしあなたのアプリに WKWebView があるなら、この問題にご注意ください。たとえページが Safari で正常に表示されていても、WKWebView でも同じとは限りません。TabBar のある環境では、WKWebView 内のページコンテンツが自動的に TabBar の一番下まで伸びる仕様があります。本来は、最下部までスクロールすると自動的に余白が足され、隠れることはないはずです。しかし position: fixed; に指定された要素はそうなりません。

解決方法は、ページの viewport META に viewport-fit=cover の宣言を加え、あわせて CSS を計算する際に env(safe-area-inset-bottom)env(safe-area-inset-top) を使えば、正しいレイアウトが得られます。

注意してほしいのは、もしページに何らかの理由で name="viewport" の META が複数存在する場合、そのすべてに加える必要があります。

終わりに

同じ UI でも、iOS 18、iOS 26、そして将来の iOS 27 で、まったく異なる挙動を示す可能性があります。各バージョンの実機を手元に用意し、とくに Navigation まわりはしっかりテストして、落とし穴を避けることをおすすめします。もう一つは、Apple の推奨する標準的なやり方と異なるデザインはできるだけ減らすことです。差が大きいほど、Apple がテストしていない領域に踏み込みやすくなり、問題に遭遇しやすくなります。

STORES ブランドアプリでは、事業者様ごとにデザインが違うので、デザイナーさんと連携し、Liquid Glass を適用した前提でも事業者様のカスタマイズの自由度をできるだけ残す対応をしました。そのため、本来使えるかもしれない回避策が使えなかったこともありました。今回の問題に直面した戦いの経験が皆さんの参考になったら幸いです。