Swift Digest

Foundation の FormatStyle 実装に対する追加の設定プロパティ

Additional Configuration Properties for Foundation’s FormatStyle Implementations

Proposal
SF-0002
Authors
Max Obermeier
Review Manager
Tina Liu
Status
Accepted

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

Foundation の FormatStyle プロトコルは、日付や数値などのデータをフォーマットするための統一的な仕組みを提供します。Foundation 自体も、Date.FormatStyleDuration.TimeFormatStyle などさまざまな実装を提供しており、利用側はそれらを組み合わせてフォーマットの細部を調整できます。

しかし、FormatStyle定義 と実際の フォーマット が別の場所・別の主体で行われる場面では、既存の API には次のような不便がありました。

作成済みの FormatStyle の設定を読み書きできない

UI コンポーネントなどが FormatStyle をパラメータとして受け取り、内部の都合に合わせてカレンダーやロケールを差し替えたい、というケースはよくあります。たとえば、カレンダーとイベントを描画するビューが、表示用の FormatStyle を受け取りつつ、ビュー側が決めたカレンダーで強制的に組み立て直したいとします。

struct CalendarView /* ... */ {
    init<F: FormatStyle>(dateFormat: F, /* ... */)
        where F.FormatInput == Date, F.FormatOutput == AttributedString
    /* ... */
}

protocol CalendarBasedFormatStyle: FormatStyle {
    func calendar(_ calendar: Calendar) -> Self
}

ところが、Foundation が提供する FormatStyle 実装の多くは、初期化後に内部のカレンダーやロケールにアクセスする手段を持っていませんでした。とくに、各 FormatStyleattributed プロパティから得られる AttributedString 版(たとえば Duration.TimeFormatStyle.AttributedMeasurement.AttributedStyle)は、ベースとなる FormatStyle のメンバーを再公開しておらず、ベース自身を取り出すこともできません。そのため、いったん attributed 版に変換してしまうと、後からカレンダーやロケールを差し替えるような protocol への適合がそもそも書けない状態でした。

Date.AttributedStyle がベースの型情報を失う

Date.FormatStyleDate.VerbatimFormatStyle の両方の attributed プロパティは、共通の Date.AttributedStyle 型を返します。Date.AttributedStyle は内部的に Date.FormatStyleDate.VerbatimFormatStyle のどちらかをラップしますが、その情報は型に現れません。型消去されているため、@dynamicMemberLookup でベースの設定を素直に再公開することもできず、片方の系統だけに protocol 適合を与えるといった使い分けもできませんでした。

Date.RelativeFormatStyle の粒度が選べない

Date.RelativeFormatStyle は「49 秒後」「1 分後」のように相対的な日時を表現しますが、利用するフィールド(秒・分・時など)を選ぶ手段がなく、たとえば「秒は使わず、分単位以上で丸めて表示したい」といった調整ができませんでした。

Duration.TimeFormatStyle の桁区切りを制御できない

Duration.TimeFormatStyle は大きな数値を 3 桁ごとの桁区切りで表示します。たとえば 10000 分は 10,000:00 のように出力され、桁区切りなしの 10000:00 を選ぶ方法はありませんでした。

Date.FormatStyle でシンボルを取り除けない

Date.FormatStyle は、生成時にロケール既定のシンボル一式(年・月・日・時・分・秒など)を持ち、hour()minute() のような関数で個別にスタイルを上書きできます。ただし、これらの関数はあくまで「指定したシンボルのスタイルを差し替える」ものであり、出力からシンボルを取り除く手段はありませんでした。「デフォルトの組み合わせから分だけ落としたい」といった減算的な指定はそのままでは書けず、必要な要素だけを再構築する必要がありました。

02 どのように解決されるのか

Foundation の各種 FormatStyle 実装に対して、設定の再公開・追加・型情報の保持を行うための API を追加・整理します。すべて FoundationPreview 0.4 以降で利用できます。

Attributed 派生スタイルでの dynamic member lookup

Duration.TimeFormatStyle.AttributedDuration.UnitsFormatStyle.AttributedMeasurement.AttributedStyle のように、ベースの FormatStyle を公開していない Attributed 派生スタイルに @dynamicMemberLookup が追加されます。これにより、ベース側のプロパティ(カレンダー、ロケール、桁区切り設定など)に対して、Attributed 版のままで読み書きできるようになります。

extension Duration.TimeFormatStyle.Attributed {
    public subscript<T>(dynamicMember key: KeyPath<Duration.TimeFormatStyle, T>) -> T { get }
    public subscript<T>(dynamicMember key: WritableKeyPath<Duration.TimeFormatStyle, T>) -> T { get set }
}

extension Duration.UnitsFormatStyle.Attributed { /* 同様 */ }
extension Measurement.AttributedStyle { /* 同様 */ }

UI ライブラリなどから受け取った Attributed 版スタイルに対して、後付けでカレンダーを差し替える、といった使い方が直接書けるようになります。

型付きの Date.FormatStyle.Attributed / Date.VerbatimFormatStyle.Attributed

Date.AttributedStyle と、それを返す Date.FormatStyle.attributed / Date.VerbatimFormatStyle.attributed プロパティは deprecated になります。代わりに、ベースの型ごとに分かれた以下の型と、それを返す attributedStyle プロパティが追加されます。

extension Date.VerbatimFormatStyle {
    public struct Attributed: FormatStyle, Sendable {
        public subscript<T>(dynamicMember key: KeyPath<Date.VerbatimFormatStyle, T>) -> T { get }
        public subscript<T>(dynamicMember key: WritableKeyPath<Date.VerbatimFormatStyle, T>) -> T { get set }
        public func format(_ value: Date) -> AttributedString
        public func locale(_ locale: Locale) -> Self
    }
    public var attributedStyle: Attributed { get }
}

extension Date.FormatStyle {
    public struct Attributed: FormatStyle, Sendable {
        public subscript<T>(dynamicMember key: KeyPath<Date.FormatStyle, T>) -> T { get }
        public subscript<T>(dynamicMember key: WritableKeyPath<Date.FormatStyle, T>) -> T { get set }
        public func format(_ value: Date) -> AttributedString
        public func locale(_ locale: Locale) -> Self
    }
    public var attributedStyle: Attributed { get }
}

Date.FormatStyle.Attributed は、Date.FormatStyle 側で提供されているシンボル指定関数(era(_:)year(_:)month(_:)hour(_:)minute(_:) など)も同じ形で持ちます。これにより、ベース型ごとに別々の protocol 適合を与えたり、Attributed のままシンボル構成を組み立てたりできます。

let attributed = Date.FormatStyle()
    .attributedStyle
    .month(.wide)
    .day(.twoDigits)
attributed.format(.now) // AttributedString

なお、deprecated 化された Date.AttributedStyleattributed プロパティは、移行期間としてしばらくは利用可能なままです。新規コードでは attributedStyle と各ベース型の Attributed を使うのが基本になります。

Date.RelativeFormatStyleallowedFields

Date.RelativeFormatStyle に、出力に使うフィールドを限定するための allowedFields が追加されます。フィールドの型は Date.ComponentsFormatStyle.Field で、typealias Field = Date.ComponentsFormatStyle.Field として再公開されます。

extension Date.RelativeFormatStyle {
    public typealias Field = Date.ComponentsFormatStyle.Field

    public var allowedFields: Set<Field>

    public init(
        allowedFields: Set<Field>,
        presentation: Presentation = .numeric,
        unitsStyle: UnitsStyle = .wide,
        locale: Locale = .autoupdatingCurrent,
        calendar: Calendar = .autoupdatingCurrent,
        capitalizationContext: FormatStyleCapitalizationContext = .unknown
    )
}

たとえば .seconds を含めずに分単位以上で表示したいときは、許可するフィールドだけを渡して初期化します。allowedFields を指定しない既存のイニシャライザを使った場合は、すべてのフィールドが許可された状態になり、これまでと同じ挙動になります。

Duration.TimeFormatStylegrouping

Duration.TimeFormatStyle に、最上位フィールドへの桁区切り規則を指定する grouping プロパティと、それを設定するメソッドが追加されます。Attributed 版にも同じメソッドが用意されます。

extension Duration.TimeFormatStyle {
    public func grouping(_ grouping: NumberFormatStyleConfiguration.Grouping) -> Self
    public var grouping: NumberFormatStyleConfiguration.Grouping { get set }
}

extension Duration.TimeFormatStyle.Attributed {
    public func grouping(_ grouping: NumberFormatStyleConfiguration.Grouping) -> Self
}

.never を指定すれば、1 万分以上のような大きな値でも桁区切りなしで 10000:00 のように出力できます。桁区切りはパターン(hourMinute など)とは独立したコントロールとして扱われるため、パターン側のイニシャライザを増やさずに後から付け替えることができます。

Date.FormatStyle.Symbol への .omitted

Date.FormatStyle のシンボル指定関数を「シンボルを取り除く」用途にも使えるよう、各シンボル型(EraYearMonthDayHourMinuteSecondTimeZone など)に .omitted という静的プロパティが追加されます。

let style = Date.FormatStyle()
style.format(date)                  // 1/1/1970, 12:00 AM (デフォルト)
style.minute(.omitted).format(date) // 1/1/1970, 12 AM (デフォルト - 分)

Date.FormatStyle の各シンボル指定関数は、これまでどおり「そのシンボルだけを表示する」記述(style.minute() のように既定値を渡す呼び出し)と、「そのシンボルを取り除く」記述(.omitted を渡す呼び出し)の両方を担う宣言的 API になります。すべてのシンボルを .omitted にすると、format(_:) の結果は空文字列になります。

.omittedDate.VerbatimFormatStyle のパターンリテラルでも使用できます。たとえば次の式は、空文字列を返す Date.VerbatimFormatStyle を作ります。

Date.VerbatimFormatStyle(
    format: "\(day: .omitted)",
    timeZone: .current,
    calendar: .current
)