Custom `DiscreteFormatStyle` in live activities

Hello,

I'm trying to display some Duration in a live activity using a custom format, with the goal of displaying a match time (only minutes) as, e.g. 33', 45' (+2), etc.

For that purpose, I'm using a TimeDataSource<Duration>, so that it also updates automatically given a starting point.

I've implemented my custom FormatStyle, trying to somehow mimic the existing UnitsFormatStyle (which perfectly works with live activities) but just changing the format.

I've added conformance to DiscreteFormatStyle, and code-wise everything seems to be ok (if I've understood things correctly). It compiles and it even works as expected in a playground.

However, when trying to use it within the live activity, I'm getting these cryptic errors in the Console.app, and the live activity just turns into a view with placeholders

Failed to fetch view from archive at index 12: SwiftUI.AnyCodable<SwiftUI.(unknown context at $1d47f6af0).SafelyCodableRequirement>.(unknown context at $1d47fe410).Errors.noType(mangledName: "7SwiftUI18TimeDataFormattingO10ResolvableVy_AA0cD6SourceVAAE15DurationStorageOys0H0V_GAK28BlickLiveActivitiesExtensionE16MatchFormatStyleVG")
[624AEC37-13D9-4927-9F41-C3092B61E214] Failed to return view entry from archive for view model with tag listItem with error: SwiftUI.AnyCodable<SwiftUI.(unknown context at $1d47f6af0).SafelyCodableRequirement>.(unknown context at $1d47fe410).Errors.noType(mangledName: "7SwiftUI18TimeDataFormattingO10ResolvableVy_AA0cD6SourceVAAE15DurationStorageOys0H0V_GAK28BlickLiveActivitiesExtensionE16MatchFormatStyleVG")

Are there any limitations when it comes to live activities and these custom formatters? This whole error doesn't make sense, since I'm only aiming to update every minute, which should just be fine, the same thing I get with the UnitsFormatStyle.

For reference, this is my playground, which just works as expected:

import Foundation
import SwiftUI
import PlaygroundSupport

extension Duration {
    struct MatchFormatStyle: DiscreteFormatStyle, Sendable {

        let periodLength: Int
        let overtime: Int

        func format(_ value: Duration) -> String {
            let (seconds, _): (Int64, Int64) = value.components
            let minutes: Int = Int(seconds) / 60
            let current: Int = periodLength + minutes + overtime
            if current > periodLength {
                return "\(periodLength)' (+\(current - periodLength))"
            } else {
                return "\(current)'"
            }
        }

        func discreteInput(before input: Duration) -> Duration? {
            let (seconds, _): (Int64, Int64) = input.components
            let minutes: Int64 = seconds / 60 - 1
            return Duration(secondsComponent: minutes * 60, attosecondsComponent: .zero)
        }

        func discreteInput(after input: Duration) -> Duration? {
            let (seconds, _): (Int64, Int64) = input.components
            let minutes: Int64 = seconds / 60 + 1
            return Duration(secondsComponent: minutes * 60, attosecondsComponent: .zero)
        }
    }
}

extension FormatStyle where Self == Duration.MatchFormatStyle {

    static func matchTime(currentTime: Int, periodLength: Int) -> Duration.MatchFormatStyle {
        return Duration.MatchFormatStyle(periodLength: periodLength, overtime: max(currentTime - periodLength, .zero))
    }
}

extension TimeDataSource<Date> {

    static func matchDuration(for currentTime: Int, periodLength: Int) -> TimeDataSource<Duration> {
        let minutesAhead: Double = Double(max(periodLength - currentTime + 1, .zero))
        return TimeDataSource<Date>.durationOffset(to: Date.now.addingTimeInterval(minutesAhead * 60))
    }
}

struct FooView: View {

    let currentTime: Int = 36
    let periodLength: Int = 45

    var body: some View {

        Text(
            TimeDataSource<Date>.matchDuration(for: currentTime, periodLength: periodLength),
            format: .matchTime(currentTime: currentTime, periodLength: periodLength)
        )
        .frame(width: 400, height: 200)
        .font(.system(size: 50))
    }
}

// Present the view controller in the Live View window
PlaygroundPage.current.setLiveView(FooView())

Any hints or suggestions are welcome, many thanks in advance.

Ok, some of the code was wrong.. I have just updated the playground, even adding some other formatter for the purposes of checking, that would show the time as mm:ss, but the original question remains the same, and it would be about using the MatchFormatStyle in the context of live activities.

import Foundation
import SwiftUI
import PlaygroundSupport

extension Duration {
    struct MatchFormatStyle: DiscreteFormatStyle, Sendable {

        let periodLength: Int
        let overtime: Int

        func format(_ value: Duration) -> String {
            let (seconds, _): (Int64, Int64) = value.components
            let current: Int = ((periodLength + overtime) * 60 + Int(seconds)) / 60
            if current > periodLength {
                return "\(periodLength)' (+\(current - periodLength))"
            } else {
                return "\(current)'"
            }
        }

        func discreteInput(before input: Duration) -> Duration? {
            let (seconds, _): (Int64, Int64) = input.components
            return Duration(secondsComponent: (seconds / 60) * 60, attosecondsComponent: .zero)
        }

        func discreteInput(after input: Duration) -> Duration? {
            let (seconds, _): (Int64, Int64) = input.components
            return Duration(secondsComponent: (seconds / 60 - 1) * 60, attosecondsComponent: .zero)
        }
    }
}

extension FormatStyle where Self == Duration.MatchFormatStyle {

    static func matchTime(currentTime: Int, periodLength: Int) -> Duration.MatchFormatStyle {
        return Duration.MatchFormatStyle(periodLength: periodLength, overtime: max(currentTime - periodLength, .zero))
    }
}

extension Duration {
    struct MatchSecondsFormatStyle: DiscreteFormatStyle, Sendable {

        let periodLength: Int
        let overtime: Int

        func format(_ value: Duration) -> String {
            let (seconds, _): (Int64, Int64) = value.components
            let current: Int = (periodLength + overtime) * 60 + Int(seconds)
            return "\(String(format: "%02d", current / 60)):\(String(format: "%02d", current % 60))"
        }

        func discreteInput(before input: Duration) -> Duration? {
            let (seconds, _): (Int64, Int64) = input.components
            return Duration(secondsComponent: seconds, attosecondsComponent: .zero)
        }

        func discreteInput(after input: Duration) -> Duration? {
            let (seconds, _): (Int64, Int64) = input.components
            return Duration(secondsComponent: seconds - 1, attosecondsComponent: .zero)
        }
    }
}

extension FormatStyle where Self == Duration.MatchSecondsFormatStyle {

    static func matchSecondsTime(currentTime: Int, periodLength: Int) -> Duration.MatchSecondsFormatStyle {
        return Duration.MatchSecondsFormatStyle(periodLength: periodLength, overtime: max(currentTime - periodLength, .zero))
    }
}

extension TimeDataSource<Date> {

    static func matchDuration(for currentTime: Int, periodLength: Int) -> TimeDataSource<Duration> {
        let minutesAhead: Double = Double(max(periodLength - currentTime, .zero))
        return TimeDataSource<Date>.durationOffset(to: Date.now.addingTimeInterval(minutesAhead * 60))
    }
}

struct FooView: View {

    let currentTime: Int = 45
    let periodLength: Int = 45

    var body: some View {
        Text(
            TimeDataSource<Date>.matchDuration(for: currentTime, periodLength: periodLength),
            format: .matchTime(currentTime: currentTime, periodLength: periodLength)
        )
        .frame(width: 400, height: 200)
        .font(.system(size: 50))

        Text(
            TimeDataSource<Date>.matchDuration(for: currentTime, periodLength: periodLength),
            format: .matchSecondsTime(currentTime: currentTime, periodLength: periodLength)
        )
        .frame(width: 400, height: 200)
        .font(.system(size: 50))
    }
}

// Present the view controller in the Live View window
PlaygroundPage.current.setLiveView(FooView())

Final improved version, in case anyone could find this useful

extension Duration {
    struct MatchFormatStyle: DiscreteFormatStyle, Sendable {

        let periodLength: Int
        let overtime: Int

        func format(_ value: Duration) -> String {
            let current: Int = ((periodLength + overtime) * 60 + Int(value.components.seconds)) / 60
            if current > periodLength {
                return "\(periodLength)' (+\(current - periodLength))"
            } else {
                return "\(current)'"
            }
        }

        func discreteInput(before input: Duration) -> Duration? {
            let minutes: Int64 = input.components.seconds / 60
            let value: Duration = .seconds(minutes * 60 - 1)
            return value
        }

        func discreteInput(after input: Duration) -> Duration? {
            let minutes: Int64 = input.components.seconds / 60
            guard minutes != 0 else {
                return input.components.attoseconds < .zero ? .zero : .seconds(60)
            }
            let value: Duration = .seconds((minutes + 1) * 60)
            return value
        }
    }
}

extension FormatStyle where Self == Duration.MatchFormatStyle {

    static func matchTime(currentTime: Int, periodLength: Int) -> Duration.MatchFormatStyle {
        return Duration.MatchFormatStyle(periodLength: periodLength, overtime: max(currentTime - periodLength, .zero))
    }
}

Same problem here, but with widgets. I've created a custom DiscreteFormatStyle and have no problems using it with TimeDataSource<Duration> while the Text()view is rendered inside the app. If used on a widget, preview show a black widget and if run on simulator/device it throws the same errors and fails to load, getting stuck on the placeholder view.

I've created a Feedback report with a sample project: FB16521246, I suggest you do the same and hopefully we'll get a solution or a fix

Custom `DiscreteFormatStyle` in live activities
 
 
Q