• 作者:老汪软件技巧
  • 发表时间:2024-09-22 04:00
  • 浏览量:

在日常开发中,经常会有展示计时类的定时更新类任务,比如要展示当前时间,比较常见的方式是通过Timer:

struct DisplayTime: View {
    @State private var date = Date.now
    @State private var timerToken: AnyCancellable?
    
    private var formatter = DateFormatter()
    
    var body: some View {
        List {
            Section("Timer") {
                Text(formatDate(date))
                    .font(.largeTitle)
            }            
        }
        .onAppear {
            timerToken = Timer.publish(every: 1, on: .main, in: .common)
                .autoconnect()
                .sink { _ in
                    date = .now
                }
        }
    }
    
    private func formatDate(_ date: Date) -> String {
        formatter.timeStyle = .medium
        
        return formatter.string(from: date)
    }
}

在iOS 15以后,SwiftUI引入了一个新的组件叫TimelineView,可以更快捷的实现上述功能:

TimelineView(.periodic(from: .now, by: 1)) { context in
	Text(formatDate(context.date))
    	.font(.largeTitle)
}

这个组件可以让使用者省去管理Timer的工作。

TimelineView

TimelineView是一个本身没有视图的View,可以按照使用者提供的调度策略来自动刷新内容视图。

extension TimelineView : View where Content : View {
	public init(_ schedule: Schedule, @ViewBuilder content: @escaping (TimelineViewDefaultContext) -> Content)
}

使用TimelineView需要两部分内容:调度策略和视图的展示逻辑

调度策略

SwiftUI提供了几种可以直接使用的调度策略:

periodic

public static func periodic(from startDate: Date, by interval: TimeInterval) -> PeriodicTimelineSchedule

periodic调度策略会按照指定的时间间隔定期执行

everyMinute

public static var everyMinute: EveryMinuteTimelineSchedule { get }

每分钟开始的时候执行

explicit

public static func explicit<S>(_ dates: S) -> ExplicitTimelineSchedule<S> where Self == ExplicitTimelineSchedule<S>, S : Sequence, S.Element == Date

按照指定的时间序列执行展示逻辑, 如下所示,指定了一个立即,5s,10s更新界面的策略

 TimelineView(.explicit([
                    .now,
                    .now.addingTimeInterval(5),
                    .now.addingTimeInterval(10)
                ])) { context in
                    Text(formatDate(context.date))
                        .font(.largeTitle)
                }

而之前的两种可以看做是时间序列可以不断刷新的explicit的特例

animation

public static func animation(minimumInterval: Double? = nil, paused: Bool = false) -> AnimationTimelineSchedule
public static var animation: AnimationTimelineSchedule { get }

animation策略如其名字所示,是为了用于完成一些动画效果的,刷新频率和屏幕的刷新频率一致。如下所示可以创建一个简易的字母循环滚动的效果。

 GeometryReader { reader in
                    TimelineView(.animation) { context in
                        let _ = { dataModel.offset -= 1
                            if dataModel.offset <= -reader.size.width {
                                dataModel.offset = 0
                            }
                        }()
                        Text("Hello TimelineView")
                            .font(.largeTitle)
                            .offset(x: dataModel.offset)
                    }
                }

调度策略说明场景

periodic

定期执行,间隔可控

显示动态数据,比如倒计时

everyMinute

每分钟开始时触发

展示当前时间或刷新分钟级数据

explicit

根据指定的时间序列触发

需要在特定时刻更新视图

animation

为动画效果设计,刷新的频率与屏幕刷新一致

实现滚动或其他高帧率效果的动画

展示逻辑

展示逻辑接收一个context参数,context包含两个属性

/// 触发刷新的时间点
public let date: Date
/// 系统建议的刷新频率
public let cadence: TimelineView<Schedule, Content>.Context.Cadence

简易跑马灯

在前面的介绍里,实现了一个简易跑马灯,但是有两个问题:

正常的跑马灯应该是从屏幕的一侧消失的内容会从屏幕的另一侧出来更新位移是在TimelineView的每个更新周期里固定加了1,但是可能这个更新间隔可能不能保证完全一样所以可以对上面的代码做些修改,变成一个简易的走马灯组件:

inal class SimpleMarqueeDataContext {
    var offset: CGFloat = 0
    
    var contentWidth: CGFloat = 0
    
    private var lastTimeInterval: TimeInterval?
    
    var containerWidth: CGFloat = 0
    
    public func updateOffset(_ timeInterval: TimeInterval) {
        if let lastTimeInterval = lastTimeInterval {
            let diff = timeInterval - lastTimeInterval
            offset += diff * 50
        }
        
        if offset >= containerWidth {
            offset = 0
        }
        
        lastTimeInterval = timeInterval
    }
}
struct SimpleMarquee<Content>: View where Content: View {
    private var content: () -> Content
    private var dataContext: SimpleMarqueeDataContext = .init()
    
    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content
    }
    
    var body: some View {
        GeometryReader { reader in
            TimelineView(.animation) { context in
                let _ = {
                    dataContext.updateOffset(context.date.timeIntervalSince1970)
                }()
                HStack(spacing: 0) {
                    content()
                        .measureWidth { width in
                            dataContext.contentWidth = width
                        }
                    content()
                        .offset(x: reader.size.width - dataContext.contentWidth)
                }
                .offset(x: -dataContext.offset)
            }
            .onAppear {
                dataContext.containerWidth = reader.size.width
            }
        }
    }
}

改动的点主要是:

组件通过context上的date和上次date的差值来计算位移的增加量为了实现从屏幕另一侧出现的效果,多增加了一个内容视图的实例这个组件可以这么使用:

struct SimpleMarqueeView: View {
    var body: some View {
        VStack {
            SimpleMarquee {
                VStack {
                    Image(systemName: "globe")
                        .imageScale(.large)
                        .foregroundStyle(.tint)
                    Text("Hello, TimelineView!")
                }
                .padding()
                .border(.red)
            }
        }
    }
}

最终的效果:

Demo