タグ: Swift

  • SwiftUI DatePicker でできないこと

    DatePicker は使いやすく、日付と時間の両方をピッカーで選択できるようになり、とても便利です。しかしながら、できそうでできないことがいくつかあり、初心者である私は採用を見送りました。2025年12月時点の話なのと、私が個人で調査した範囲の話である点を前提としていただければと思います。

    基本的な使い方

    Swift
    @State private var date = Date()
    
    DatePicker(
        "日時",
        selection: $date,
        // 日付と時間のピッカーを表示する。ここに問題がある
        displayedComponents: [.date, .hourAndMinute]
    )
    .datePickerStyle(.compact)
    .environment(.locale, Locale(identifier: "ja_JP"))

    このように書くだけで、日付と時間が横並びで表示されそれぞれをピッカーで選択できます。

    テキストの背景に素敵な灰色の楕円を描画してくれます。
    カスタマイズしないのであれば、素晴らしいと思います。

    1. 左寄せにすると謎の隙間ができる

    こちらを左寄せにしようとすると、途端に難しくなりました。

    Swift
    HStack(spacing: 0) {
        Image(systemName: "(toDay(date)).calendar")
            .foregroundColor(.green)
            .font(.system(size: 24))
        DatePicker(
            "",
            selection: $date,
            displayedComponents: [.date, .hourAndMinute]
        )
        .datePickerStyle(.compact)
        .environment(
            .locale,
            Locale(identifier: "ja_JP")
        )
        .labelsHidden()
    }

    .labelsHidden() を入れたので、本来ラベルが表示される領域がなくなるはずですが、なぜか領域が残っています。(赤線部分)
    背景色を付けてもう少し分かりやすくします。

    Swift
    HStack(spacing: 0) {
        Image(systemName: "(toDay(date)).calendar")
            .foregroundColor(.green)
            .font(.system(size: 24))
            .background(.blue)  // 追加
        DatePicker(
            "",
            selection: $date,
            displayedComponents: [.date, .hourAndMinute]
        )
        .datePickerStyle(.compact)
        .environment(
            .locale,
            Locale(identifier: "ja_JP")
        )
        .labelsHidden()
        .background(.orange)  // 追加
    }

    コードにはないスペースが存在していることが分かります。DatePicker の内部でスペースを描画しているようです。
    不思議なことに、何度か再起動するとスペースが入らないこともありました。確実に再現しない点も悩ましいです。

    2. タップ可能領域を調整できない

    DatePicker 自体を隠して、別の表示領域を作り、そちらのタップイベントでDatePicker を呼び出す方式を考えました。Javascript ではよくあるやり方と思います。
    しかし、DatePicker にPicker だけ表示するインターフェースが存在しないため、この方式実現できないようです。

    3. macos は別の実装となっている

    DatePicker に限りませんが、SwiftUI は全体的にmacos の見た目はios と同じレベルまで洗練されていはいない印象があります。
    DatePicker については、.wheel がコンパイルレベルで選択できなくて、.compact は.graphical と同じ挙動となります。.graphical はios とは見た目が大きく異なります。
    こちらも残念です。

    参考 macos の見た目

    まとめ

    長い時間悩みましたが、私には解決できないため、独自に実装することとしました。
    こちらのコードがとても綺麗で参考にさせてもらいました。
    https://github.com/SimformSolutionsPvtLtd/SSDateTimePicker

    また、iPhone のGoogle カレンダーアプリも、カレンダー部分は独自実装と思われます。せっかく見た目が綺麗なDatePicker なので、カスタマイズができれば少し活躍できそうな気がします

    参考 iPhone 版google カレンダーアプリ

    アップルさんの今後に期待しています。

    【広告】

  • 開発中のmacOS アプリでKeychain への書き込みが-24299 エラーとなる

    急に発生し、調べても調べても解決できずに困ってしまった。
    AI の回答は、App Sandbox やentitlements に問題があるというものだったが、いずれも該当せずでした
    結論をいうと、アプリのBundle Identifier が変更されたため、別のアプリと扱われたことが原因だった。

    再発手順

    • 古いアプリでKeychain に書き込み
    • アプリを更新(Bundle Identifier を変更)
    • 新しいアプリで1. と同じキーの値を更新しようとすると –> エラー発生

    確認手順

    • Mac のKeychain を起動する
    • デフォルトキーチェーン: ログイン > パスワードを選択
    • 変更日で並び替えると探しやすい
    • アプリで使用しているキーを一覧から探してクリックする
    • アクセス制御タブを表示する

    こちらのアプリが同一でないと、Keychain の値を更新・削除できません。

    対応案

    Mac の「キーチェーンアクセス」アプリから対象のキーを削除すると、アプリから再度書き込みできます。キー名称を右クリックすると削除できます。

    開発中にBuneld Identifier を変更する際はご注意ください。(あまりないかもですが)

    【広告】

  • バックグラウンド処理から@Published 付き変数を更新する(swift)

    このエラーに悩んだ。

    Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

    XCode を探すと紫色の警告が出ていた。(探さないと分からなかった)


    どうやら、バックグラウンドの処理で、@Published がついた変数を変更するとこのエラーが出るようだ。

    私が開発中のアプリは、アプリ起動直後にローディング画面を表示し、そのバックグラウンドで初期化処理を行なっている。
    バックグラウンドから@Published 付き変数を無邪気に更新すると、上のエラーが出る。

    バックグラウンドスレッドの作り方

    そもそもだが、メインスレッドを使わず、バックグラウンドスレッドを作るには下のように書く。

    Task を使う例

    Task {
      await initMethodA()
    }


    async let :() を使う例

    async let initB: () = try initMethodB()
    async let initC: () = try initMethodC()
    _ = try await (initB, initC)

    ※initMethodB とinitMethodC が並列実行される

    そして、バックグラウンドスレッドから以下のように変数を更新すると上のエラーになる。

    func initMethodA() async throws {
      someVM.publishedValue = "newValue"
    }

    バックグラウンドスレッドからMainActor で実行させる書き方

    私の調査では2つのやり方が見つかった。

    MainActor.run を使うやり方

    func initMethodA() async throws {
      await MainActor.run {
        someVM.publishedValue = "newValue"
      }
    }

    MainActor.run の内側はメインスレッドで実行される。
    メインスレッドで変数を更新すればエラーは起きない。

    気をつけなければならないのは、await を使うことができない点だ。
    使いたい場合は、先にawait が必要な処理を行っておくとよい。

    func initMethodA() async throws {
      // 先に非同期処理を実行
      let newValue = await someAsyncMethod()
      await MainActor.run {
        // メインスレッドは変数の代入のみ
        someVM.publishedValue = newValue
      }
    }

    Task { @MainActor in を使うやり方

    Task { @MainActor in
      someVM.publishedValue = await someAsyncMethod()
    }

    もう一つがこちら。await が使えるので使いやすい。コードも簡潔で見通しも良い。

    しかし、Task がメインスレッドで実行されて、someAsyncMethod はメインか別スレッドのどちらかで実行されて、publishedValue への代入はメインスレッドで実行される。
    MainActor.run に比べて、メインスレッドの出番が多い。

    私が思う解決策

    @Published にprivate(set) を追加することで、外部から変更不可にできる。代わりに、ViewModel にsetter を作成してsetter の中でMainActor.run を使うことで、呼び出し元がメインスレッドかどうかを考えずに済む。

    class SomeViewModel: ObservableObject {
      @Published private(set) var publishedValue = ""
      func setValue(_ newValue: String) async {
        await MainActor.run {
          publishedValue = newValue
        }
      }
    }

    残念だがこれは完璧ではない。画面から更新する変数の場合、private(set) を付けられないことがある。例えばトグルの場合だ。

    Toggle("有効にする",isOn: $someVM.publishedValue)

    ※publishedValue はView から更新する必要がある


    このようなケースは仕方ないので、個別に対応するしかない。(と思う)

    【広告】

  • SwiftUIでスクロールに応じてメニューを隠す処理を実装してみた

    スマホアプリは小さな画面を有効活用するため、必要のない時はメニューを隠しておくとUX 向上につながります。
    メニューを常時表示する方が実装は簡単ですが、今回はスクロールに応じて表示・非表示を切り替えるUIを実装してみました。


    スクロール位置の取得

    Swift でスクロール位置を取得するには PreferenceKey を使用します。これは、スクロールする View と位置を受け取りたい親 View 間で値を連携する手段です。
    今回は、LazyVStack のスクロール位置を取得して、ContentsView で位置を受け取りメニュー表示の判断を行います。
    私はjavascript のように、window.scrollY で取得できるだろうと安易に考えていましたが、それよりは少し複雑でした。

    スクロールするView

    var body: some View {
        ScrollView {
            LazyVStack(alignment: .leading, spacing: 0) {
                // 大量のコンテンツ(省略)
            }
            // padding については後述
            .padding(.top, topMenuHeight)
            .padding(.bottom, bottomMenuHeight)
            .background(
                GeometryReader {
                    Color.clear.preference(
                        key: ScrollOffsetPreferenceKey.self,
                        // 画面上部のスクロール位置を取得してPreferenceKey で連携
                        value: $0.frame(in: .scrollView).minY
                    )
                }
            )
        }
    }

    Color.clear は画面に何も表示しないです。.preference を設定するために使用しています

    位置を取得するContentsView

    var body: some View {
        GeometryReader { geo in
            ZStack(alignment: .top) {
                // 画面コンテンツ(省略)
            }       .onPreferenceChange(ScrollOffsetPreferenceKey.self) { newY in
                // スクロール発生時にnewY が連携される
                refreshMenu(newY: newY, geo: geo)
            }
        }
    }

    PreferenceKey の実装(お作法に従ったもの)

    struct ScrollOffsetPreferenceKey: PreferenceKey {
        static var defaultValue: CGFloat = 0
        static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
            value += nextValue()
        }
    }

    Color.clear.preference から.onPreferenceChange へスクロール位置が連携されます。


    メニュー表示判定

    判定自体は比較的簡単です。
    スクロール位置は1フレーム毎に送付されるので、前回のスクロール位置を記憶しておくことで差分を算出できます。
    私は差分を15(ポイント)で判断するのが良いと思いました。
    そのため、+15 以上であれば下向きスクロールなのでメニューを隠す、-15 以下であれば、上向スクロールなのでメニューを表示する、これが基本になります。

    加えて、スクロールの上部に来た時はメニューが常に表示された方が良いです。
    スクロール位置が10 以下の場合は差分に関係なく表示するようにしました。

    private func calcMenuVisibilityAction(_ newY: CGFloat, _ oldY: CGFloat) -> MenuVisibilityActionEnum {
        let threshold: CGFloat = 15
        let topArea: CGFloat = 10
        if topArea < newY || topArea < oldY {
        // 上部に近い場合は常にメニューを表示
            return .show
        }
        let delta = newY - oldY
        if threshold < delta {
            // 上スクロール:メニューを表示
            return .show
        } else if threshold < -delta {
            // 下スクロール:メニューを隠す
            return .hide
        }
        // その他は何もしない
        return .none
    }

    メニュー位置の算出

    上のメニューについて考えてみます。
    表示するときは、上メニューのY座標を0 にすることで表示されます。こちらは簡単です。
    隠す時は少し厄介で、iPhone では、画面上部のベゼル部分(ノッチなど)を考慮する必要があります。
    メニューの高さにベゼルの高さを加えた分を画面外へ移動させます。

    ベゼルの高さはGeometoryReader のsafeAreaInsets.top で取得できます。
    画面外へのオフセットはこのようなコードで取得できます。(topMenuHeight は固定値)
    topMenuOffset = 0 – geo.safeAreaInsets.top – topMenuHeight

    下のメニューも同様にベゼルを考慮しつつ、画面外に移動させる量を算出します。


    スクロールView に余白を追加

    LazyVStack に.padding で上下メニューの高さを追加します。
    これがないと、メニューの裏側にコンテンツが隠れてしまいます。

    ただし、一番下までスクロールした際に、.padding(.bottom) で追加した余白がそのまま表示されてしまいます。
    見た目的に少し間延びする印象があるのですが、完全に解決するのは難しく、今回は割り切ってそのままにしています。
    ちなみに、GmailのiOSアプリでも同じような余白が見られたため、それほど大きな問題にはならないと判断しました。


    まとめ

    • PreferenceKey を使ってスクロール座標を取得する
    • 前回のスクロール位置と比較することで、メニュー表示の判定が可能
    • ベゼルを考慮してメニューを隠すときの表示位置を算出
    • メニューが重ならないように padding を入れる必要がある

    ソース

    【広告】