在 Apple Watch 上的 SwiftUI 中,更新描述自具有 Always On 状态的日期以来的间隔的字符串的最佳方法是什么?

In SwiftUI on Apple Watch, what is the best way to update a String that describes the interval since a Date with Always On State?

我正在构建一个使用 SwiftUI 的 Apple Watch 应用程序。

新数据会定期从外部来源进入应用程序,我通过 @Published Date(称为 lastUpdatedDate)在 ObservableObject (store).

在应用程序中,我想使用 SwiftUI Text 结构来向用户指示数据更新的时间。

我正在权衡不同的选择,我想知道对于这样的事情最好的做法是什么。

解决方案 #1 - Text.DateStyle

我试过的一个非常简单的方法是使用 Text.DateStyle.relative:

Text(store.lastUpdatedDate, style: .relative)

这在某种程度上以我想要的方式工作,因为它使文本与日期发生后的相对间隔保持最新,但是 the text is not customizable。我不希望它显示秒数,只显示分钟或小时。

解决方案 #2 - 计算 属性

在我想显示 TextView 中,我可以访问 ObservableObject,并添加了一个使用转换函数的计算 属性 DateString 的相对日期(例如,5 分钟前 )使用 Date 上的 extensionRelativeDateTimeFormatter

计算属性:

private var lastUpdatedText: String {
    store.lastUpdatedDate.timeAgoDisplay()
}

日期扩展:

extension Date {
    func timeAgoDisplay() -> String {
        let formatter = RelativeDateTimeFormatter()
        formatter.unitsStyle = .full
        return formatter.localizedString(for: self, relativeTo: Date())
    }
}

这似乎是迄今为止我找到的最佳解决方案,因为它会在每次打开应用程序时自动更新。然而,缺点是它似乎只在应用程序进入前台时更新(当另一个应用程序或钟面先前处于活动状态时)。这可能没问题,但在具有常亮显示屏的 Series 5、Series 6 和 Series 7 上可能会更好。在 watchOS 8 上,使用 Always On State,当手腕落下和再次抬起手腕时,此文本会保留在屏幕上。文本可能会过时,因为这些事件不会导致 lastUpdatedText 计算的 属性 再次更新。

更新: 这实际上不是一个很好的解决方案。通过更多的测试,我发现每次打开应用程序时它都在更新是侥幸。它只是在打开应用程序时重新计算 属性,因为视图中的其他项目正在刷新,并且它本身不会在每次打开应用程序时重新计算。

其他可能的解决方案

我考虑在 ObservableObject 中添加第二个 @Published 变量,这是一个包含相对时间间隔的 String。这样做的缺点是我必须手动初始化和更新该变量。如果这样做,我可以根据 ExtensionDelegate 中的生命周期函数手动更新文本,例如 applicationWillEnterForeground(),但这仅在应用程序首次进入前台时处理(这与计算的更新频率相同 属性).我还没有找到任何方法来检测手腕何时下降或再次抬起,所以我唯一能保持最新状态的方法似乎是设置一个或多个已发布的 Timers,并更新 @Published String 每分钟,每次应用程序进入后台并 returns 进入前台时禁用和设置计时器。这看起来是个好的解决方案吗?我忽略了更好的解决方案吗?


解决方案

根据接受的答案,我能够使用 TimelineView 定期更新。

尽管我只对显示的文本使用分钟粒度,但我希望文本在实际刷新数据时更新到秒。为了完成这部分,我首先为 Date 添加一个新的 extension:

extension Date {
    func withSameSeconds(asDate date: Date) -> Date? {
        let calendar = Calendar.current
        var components = calendar.dateComponents([.era, .year, .month, .day, .hour, .minute, .second], from: self)
        let secondsDateComponents = calendar.dateComponents([.era, .year, .month, .day, .hour, .minute, .second], from: date)
        
        components.second = secondsDateComponents.second
        
        return calendar.date(from: components)
    }
}

然后,我创建了我的视图,使用 TimelineView 在数据刷新后每分钟更新一次文本:

struct LastUpdatedTextView: View {
    
    @ObservedObject var store: DataStore
    
    var body: some View {
        if let nowWithSameSeconds = Date().withSameSeconds(asDate: store.lastUpdatedDate) {
            TimelineView(PeriodicTimelineSchedule(from: nowWithSameSeconds,
                                                  by: 60))
            { context in
                Text(store.lastUpdatedText(forDate: lastUpdatedDate))
            }
        } else {
            EmptyView()
        }
    }
}

Apple 在 WWDC21

Build A Workout App 中解决了这个问题

他们使用 TimelineViewcontext.cadence == .live 告诉他们的格式化程序在手表处于始终开启状态时不显示毫秒数。

您可以使用该代码来确定要显示的内容。

struct TimerView: View {
    var date: Date
    var showSubseconds: Bool
    var fontWeight: Font.Weight = .bold
    
    var body: some View {
        if #available(watchOSApplicationExtension 8.0, watchOS 8.0, iOS 15.0, *) {
            //The code from here is mostly from https://developer.apple.com/wwdc21/10009
            TimelineView(MetricsTimelineSchedule(from: date)) { context in
                ElapsedTimeView(elapsedTime: -date.timeIntervalSinceNow, showSubseconds: context.cadence == .live)
            }
        } else {
            Text(date,style: .timer)
                .fontWeight(fontWeight)
                .clipped()
        }
    }
}
@available(watchOSApplicationExtension 8.0, watchOS 8.0, iOS 15.0,*)
private struct MetricsTimelineSchedule: TimelineSchedule {
    var startDate: Date
    
    init(from startDate: Date) {
        self.startDate = startDate
        
    }
    
    func entries(from startDate: Date, mode: TimelineScheduleMode) -> PeriodicTimelineSchedule.Entries {
        PeriodicTimelineSchedule(from: self.startDate, by: (mode == .lowFrequency ? 1.0 : 1.0 / 30.0))
            .entries(from: startDate, mode: mode)
    }
}
struct ElapsedTimeView: View {
    var elapsedTime: TimeInterval = 0
    var showSubseconds: Bool = false
    var fontWeight: Font.Weight = .bold
    @State private var timeFormatter = ElapsedTimeFormatter(showSubseconds: false)
    
    var body: some View {
        Text(NSNumber(value: elapsedTime), formatter: timeFormatter)
            .fontWeight(fontWeight)
            .onChange(of: showSubseconds) {
                timeFormatter.showSubseconds = [=10=]
            }
            .onAppear(perform: {
                timeFormatter = ElapsedTimeFormatter(showSubseconds: showSubseconds)
            })
    }
}