【SwiftUI】macOS のネイティブにそっくりなカスタムアラートを実装した【Clip Hold 開発日記】

みなさんこんにちは、たいくんです。
せっかくこのブログを作ったのに更新頻度が低すぎて、このまま眠らせておくのはもったいないと感じたので、このブログの当初の目的を思い出し、今日から自作アプリの開発日記を書くことにしました。
毎日投稿するわけではありませんが、日々の開発で進展があった時・開発に必要な情報を調査した時などに記事にしようと思います。
一つ一つの記事の内容は短いと思いますが、アプリ開発の裏側が気になる方などに楽しんでいただければ幸いです。

最初の開発日記は、私のクリップボードマネージャーであるClip Holdのアップデートで実装される機能のお話です。
1つ目の開発日記ということもあり、少しだけ気合いを入れて書いてしまいましたが、次回からはもう少し短くなる…かもしれません。

広告

大容量ファイルコピー時のアラート表示

ネイティブアラートの問題が発覚した

Clip Holdには大きなファイルをコピーしたときに履歴に保存するかどうかを求めるアラートを表示する機能があります。
設定したある一定の容量を超えるファイルがコピーされるとアラートが表示されるように設定でき、何でもかんでもコピーしてストレージが圧迫してしまうのを防止することができます。

このアラートは、macOSの様々なところで使われている標準的なアラートと同じものをSwiftUIで実装して表示させていました。
私がこの機能を実装したときに想定していた機能は、当初の想定通りに動いていたので全く問題を感じていませんでしたが、最近実装した新機能(「クイックオーバーレイ」と名づけました)をテストしているときにとある問題に気が付きました。

その問題とは、「アラートが表示されているときにメインスレッドでの処理がブロックされてしまう(後回しにされてしまう)」というものです。
クイックオーバーレイの詳細については正式リリース時にドキュメントを公開する予定なので、公開後にそちらをご確認いただければと思いますが、この機能はショートカットキーを押している間に開かれる機能で、このアラートが表示されているときにクイックオーバーレイがうまく機能していない(キーを押しても反応しない)ことに気がつきました。
しかもアラートを閉じると、キーを押していないのに先ほど押した情報が遅れてやってきて、クイックオーバーレイが開いたままになってしまう問題が発生しました。
さらにテストしていると、アラートが表示されている間はクリップボードに変更があっても履歴に追加されず、コピーした項目を自動でペーストするクイックペースト機能も止まっていることがわかりました。

考えてみると、macOSのアプリでアラートが表示されている間は親ウィンドウをクリックしてもビープ音が鳴るだけで、アラートを閉じるまで操作ができませんよね。例えばゴミ箱を空にするアラートが表示されている間はFinderのウィンドウやデスクトップをクリックしても操作できません。
macOSのアラートは、Appleのヒューマンインターフェースガイドラインに「アラートを使って、ユーザが今すぐ必要とする重要な情報を提示できます。」と書かれている通り、通常の動作に割り込んでユーザに操作を求めるために表示するものですので、アラートの操作が完了するまで後ろ側で勝手に変更されないようにメインスレッドの処理を後回しにするのは非常に理にかなっているように思います。

しかしClip Holdにおいては、Clip Hold自体がバックグラウンドアプリであり、ファイルコピーのアラートが表示されたときにユーザが気づかないまま(アラートが別のウィンドウの後ろに隠れてしまうなど)だと、基本機能が全て停止してしまう方が問題ですので、この問題は解決しなければなりません。

広告

ネイティブにそっくりなカスタムアラートを実装することで解決

そこで、標準のアラートを使用することを諦め、メインスレッドをブロックしないカスタムアラートを実装し、ネイティブアラートに可能な限りそっくりなものを作ることで解決することにしました。

こうしてできたものがこの画像の右側にあるアラートです。左側がゴミ箱を空にしようとしたときに表示されるmacOSのネイティブアラート。
macOSの仕様で、アラートボタンの文字数が多い場合はボタンが縦並びになるため同じレイアウトでの比較では無いものの、左側のネイティブアラートに非常にそっくりだと思いませんか?

こちらがダークモードバージョンです。

現行のmacOS Tahoeのデザインは比較的再現しやすかったです。実はよく見るとボーダーの色合いや影の大きさなどは若干異なりますが、しばらく調整をしていてもうまくいかなかったため、これで完成としました。

アラートの背景には以下のように指定しました。

Color.clear
    .glassEffect(in: .rect(cornerRadius: 28.0))

Liquid Glassの通常バリアントで角丸を28ポイントに設定することで、ネイティブのアラートと全く同じ背景と角丸になりました。

.overlay( // 内側の明るいボーダー
    Group {
        if colorScheme == .dark {
            RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
                .strokeBorder(Color.white.opacity(0.3), lineWidth: 2)
                .blendMode(.overlay)
        } else {
            RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
                .strokeBorder(Color.white.opacity(0.3), lineWidth: 2)
                .blendMode(.normal)
        }
    }
)
.overlay( // 外側の黒いボーダー
    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
        .strokeBorder(colorScheme == .dark ? Color.black : Color.black.opacity(0.25), lineWidth: 1)
)

ボーダー部分についてはglassEffectだけでは再現できなかったため、明るいボーダーと黒いボーダーの2つを重ねて近づけました。
Liquid Glassでは、特にダークモードでガラスの端が背景の色に合わせて明るく反射するような光がありますが、これを再現するために、半透明の白いボーダーに.blendMode(.overlay)をつけて背景の色を鮮やかにしつつ明るさを持ち上げました。
残念ながら完璧に再現できたわけでは無いですが、スクリーンショットを撮って拡大でもしない限りは、ほとんどそっくりに見えると思います。

広告

こちらはmacOS Sonoma(とSequoia)バージョンです。

同じくダークモードバージョン。

古いバージョンのアラート再現は意外と苦戦しました。
アラートの背景はregularMaterialがかなり近いのでそのまま使うことでそっくりになりましたが、問題はアラートのボタンです。
古いmacOSでは通常スタイルのボタンを配置すると、ボタンの背景が白色(ダークモードではグレー)になるだけでなく、細いボーダーや影が付与されてしまいます。
システム設定などで使用されている通常のボタンと同じ見た目ですが、古いmacOSのアラートでは特別なスタイルが使用されているようです。

そこで、標準では再現できないため、以下のカスタムスタイルを実装して再現しました。

struct LegacyAlertButtonStyle: ButtonStyle {
    var isProminent: Bool
    @Environment(\.controlActiveState) var controlActiveState
    @Environment(\.colorScheme) var colorScheme
    
    func makeBody(configuration: Configuration) -> some View {
        let isActive = (controlActiveState != .inactive)
        let showProminent = isProminent && isActive
        
        let fgColor: Color = showProminent ? .white : .primary
        
        let topGradientColor = colorScheme == .dark ? Color.white.opacity(0.08) : Color.white.opacity(0.15)
        let bottomGradientColor = colorScheme == .dark ? Color.black.opacity(0.15) : Color.clear
        let nonProminentOpacity = colorScheme == .dark 
            ? (configuration.isPressed ? 0.55 : 0.35)
            : (configuration.isPressed ? 0.3 : 0.15)
        
        configuration.label
            .padding(.vertical, 6)
            .background(
                Group {
                    if showProminent {
                        Color.accentColor
                            .overlay(
                                LinearGradient(
                                    colors: [topGradientColor, bottomGradientColor],
                                    startPoint: .top,
                                    endPoint: .bottom
                                )
                            )
                            .overlay(
                                configuration.isPressed 
                                    ? (colorScheme == .dark ? Color.white.opacity(0.15) : Color.black.opacity(0.1))
                                    : Color.clear
                            )
                    } else {
                        if colorScheme == .dark {
                            Color.clear
                                .background(Material.ultraThinMaterial)
                                .environment(\.controlActiveState, .active)
                                .saturation(2)
                                .overlay(Color.primary.opacity(nonProminentOpacity))
                        } else {
                            Color.primary.opacity(nonProminentOpacity)
                        }
                    }
                }
            )
            .foregroundStyle(fgColor)
            .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
    }
}

ライトモードではボタンの背景をプライマリーカラーの半透明でかなりネイティブに近づきましたが、ダークモードでは背景の色合いによってボタンの背景色がそこそこ違うことに気づいたので、半透明のultraThinMaterialに対して彩度を2倍に上げることで近づけました。

もう一つ地味に苦戦したのがプロミネントスタイル(アクセントカラーでハイライトされているプライマリーアクションボタンのスタイル)の再現です。
古いmacOSではプロミネントスタイルの色がグラデーションになっているので、色合いの調整が少し大変でした。
こちらも完全に同じでは無いものの、ぱっと見ではそっくりに見えるかなと思います。

.overlay( // 内側の明るいボーダー
    Group {
        RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
            .strokeBorder(Color.white.opacity(colorScheme == .dark ? 0.2 : 0.3), lineWidth: 2)
            .blendMode(colorScheme == .dark ? .plusLighter : .normal)
    }
)
.overlay( // 外側の黒いボーダー
    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
        .strokeBorder(colorScheme == .dark ? Color.black : Color.black.opacity(0.3), lineWidth: 1)
)

ボーダーはこんな感じです。ダークモードの時は明るいボーダーの色をさらに上げるために、ブレンドモードを.plusLighterに設定しています。

広告

カスタムアラートを実装したメリット

カスタムアラートを実装したことで、メインスレッドがブロックされないこと以外にメリットがありました。それは、表示されたアラートを最前面に固定表示できることです。

ネイティブのアラートでは、アラートが他のウィンドウの後ろに回り込んでしまいます。特にClip HoldのようなバックグラウンドアプリではDockアイコンが表示されないため、複数のウィンドウが表示されている状態でアラートが後に回り込んでしまうと、ユーザはアラートが見つけづらくなってしまいます。
しかも、Dockアイコンが表示されないアプリはMission Controlにも表示されないため、ユーザは後ろに回り込んだアラートを見つけるために手前にあるウィンドウを閉じるか別の場所に動かす必要があったのです。

カスタムアラートはウィンドウとして実装されているので、パネルレベルを上げることで最前面ウィンドウとして実装することができます。
今回実装したカスタムアラートでは.floatingレベルに設定しているため、多くの最前面ウィンドウと同じレベルに表示されます。これにより、ユーザが誤ってアラート以外のウィンドウにフォーカスを当ててしまった場合でもアラートは常に最前面に表示されるため、そのまま気づかないで無視されてしまう可能性が低下します。
それに、今回実装したカスタムアラートはメインスレッドをブロックしないので、万が一アラートが表示されたまま操作されずに無視されてしまっても、Clip Holdの基本機能は止まらずに動き続けるため、「いつの間にか表示されていたアラートを閉じ忘れて、しばらくの間履歴が追加されていなかった!」という事故を防ぐことができるようになりました。

バージョン1.7.0は開発中です

今回実装したカスタムアラートは、Clip Holdの次期バージョンである1.7.0に搭載される予定です。
1.7.0では便利な機能がいくつか追加される予定で、これからも正式リリースに向けてさらなるバグ修正や改善などを行う予定ですので、正式リリースまでしばらくお待ちください!
なお、1.7.0は開発中であり、この記事で紹介した内容は1.7.0の正式リリースまでに変更される可能性があります。

Clip Holdのアップデート情報のお知らせは私のXアカウント(@i_am_taikun)で行いますので、ぜひフォローしていただけると幸いです。

最後まで読んでくださりありがとうございました。それでは、また!

広告

コメントはこちらから!

記事について気になるところや質問、ご指摘など、以下のコメント欄から気軽にコメントしていただけるとうれしいです!

※コメントの投稿には、無料で作成できるGitHubアカウントが必要です。コメント欄が表示されない場合はページを再読み込みしてみてください。

コメント欄ではMarkdown記法が使用でき、画像を挿入することもできます。ここで使用できるMarkdown記法の詳しい使い方は、入力欄の右下にあるアイコンをクリックすることで確認することができます。