mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Fix Frame Drop in button animation
This commit is contained in:
parent
33a854dffb
commit
2db2fb20a5
1 changed files with 268 additions and 79 deletions
|
|
@ -19,10 +19,16 @@ typealias PlatformFont = UIFont
|
||||||
struct TimerLiveActivity: View {
|
struct TimerLiveActivity: View {
|
||||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
@ObservedObject var timerManager = TimerManager.shared
|
@ObservedObject var timerManager = TimerManager.shared
|
||||||
|
@ObservedObject var lockScreenManager = LockScreenManager.shared
|
||||||
@State private var isHovering: Bool = false
|
@State private var isHovering: Bool = false
|
||||||
@State private var showTransientLabel: Bool = false
|
@State private var showTransientLabel: Bool = false
|
||||||
@State private var labelHideTask: DispatchWorkItem?
|
@State private var labelHideTask: DispatchWorkItem?
|
||||||
@State private var isControlWindowVisible: Bool = false
|
@State private var isControlWindowVisible: Bool = false
|
||||||
|
@State private var pendingControlWindowTask: Task<Void, Never>?
|
||||||
|
@State private var controlWindowSuppressionTask: Task<Void, Never>?
|
||||||
|
@State private var hasPendingControlWindowSync: Bool = false
|
||||||
|
@State private var pendingControlWindowForceRefresh: Bool = false
|
||||||
|
@State private var isControlWindowSuppressed: Bool = false
|
||||||
@Default(.timerShowsCountdown) private var showsCountdown
|
@Default(.timerShowsCountdown) private var showsCountdown
|
||||||
@Default(.timerShowsProgress) private var showsProgress
|
@Default(.timerShowsProgress) private var showsProgress
|
||||||
@Default(.timerShowsLabel) private var showsLabel
|
@Default(.timerShowsLabel) private var showsLabel
|
||||||
|
|
@ -39,6 +45,25 @@ struct TimerLiveActivity: View {
|
||||||
private var wingPadding: CGFloat { 22 }
|
private var wingPadding: CGFloat { 22 }
|
||||||
private var ringStrokeWidth: CGFloat { 3 }
|
private var ringStrokeWidth: CGFloat { 3 }
|
||||||
private var transientLabelDuration: TimeInterval { 4 }
|
private var transientLabelDuration: TimeInterval { 4 }
|
||||||
|
private let controlWindowResumeDelay: TimeInterval = 0.22
|
||||||
|
|
||||||
|
private struct ControlWindowSyncKey: Equatable {
|
||||||
|
var isTimerActive: Bool
|
||||||
|
var timerNameSignature: Int
|
||||||
|
var isFinished: Bool
|
||||||
|
var isOvertime: Bool
|
||||||
|
var isPaused: Bool
|
||||||
|
var closedNotchSize: CGSize
|
||||||
|
var screenName: String?
|
||||||
|
var hideOnClosed: Bool
|
||||||
|
var controlWindowEnabled: Bool
|
||||||
|
var showsLabel: Bool
|
||||||
|
var showTransientLabel: Bool
|
||||||
|
var showsCountdown: Bool
|
||||||
|
var showsProgress: Bool
|
||||||
|
var progressStyleIdentifier: String
|
||||||
|
var activeSourceIdentifier: String
|
||||||
|
}
|
||||||
|
|
||||||
private var ringWrapsIcon: Bool {
|
private var ringWrapsIcon: Bool {
|
||||||
showsRingProgress && showsCountdown
|
showsRingProgress && showsCountdown
|
||||||
|
|
@ -140,17 +165,48 @@ struct TimerLiveActivity: View {
|
||||||
return timerPresets.first { $0.id == presetId }?.color
|
return timerPresets.first { $0.id == presetId }?.color
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var middleSectionWidth: CGFloat {
|
||||||
|
vm.closedNotchSize.width + (isHovering ? 8 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var adjustedNotchHeight: CGFloat {
|
||||||
|
vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var controlWindowSyncKey: ControlWindowSyncKey {
|
||||||
|
ControlWindowSyncKey(
|
||||||
|
isTimerActive: timerManager.isTimerActive,
|
||||||
|
timerNameSignature: timerManager.timerName.hashValue,
|
||||||
|
isFinished: timerManager.isFinished,
|
||||||
|
isOvertime: timerManager.isOvertime,
|
||||||
|
isPaused: timerManager.isPaused,
|
||||||
|
closedNotchSize: vm.closedNotchSize,
|
||||||
|
screenName: vm.screen,
|
||||||
|
hideOnClosed: vm.hideOnClosed,
|
||||||
|
controlWindowEnabled: controlWindowEnabled,
|
||||||
|
showsLabel: showsLabel,
|
||||||
|
showTransientLabel: showTransientLabel,
|
||||||
|
showsCountdown: showsCountdown,
|
||||||
|
showsProgress: showsProgress,
|
||||||
|
progressStyleIdentifier: progressStyle.rawValue,
|
||||||
|
activeSourceIdentifier: timerManager.activeSource.rawValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private var shouldShowControlWindow: Bool {
|
private var shouldShowControlWindow: Bool {
|
||||||
#if os(macOS)
|
if !controlWindowEnabled { return false }
|
||||||
let lockSuppressed = !LockScreenManager.shared.currentLockStatus
|
if shouldDisplayLabel { return false }
|
||||||
#else
|
|
||||||
let lockSuppressed = true
|
let timerEligible = timerManager.isTimerActive && !timerManager.isExternalTimerActive
|
||||||
#endif
|
if !timerEligible { return false }
|
||||||
return controlWindowEnabled
|
|
||||||
&& !shouldDisplayLabel
|
let notchReady = vm.notchState == .closed && !vm.hideOnClosed
|
||||||
&& timerManager.isTimerActive
|
if !notchReady { return false }
|
||||||
&& !timerManager.isExternalTimerActive
|
|
||||||
&& lockSuppressed
|
if isControlWindowSuppressed { return false }
|
||||||
|
if lockScreenManager.isLocked { return false }
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat {
|
private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat {
|
||||||
|
|
@ -176,7 +232,91 @@ struct TimerLiveActivity: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
baseTimerLayout
|
||||||
|
.onHover { hovering in
|
||||||
|
withAnimation(.smooth(duration: 0.18)) {
|
||||||
|
isHovering = hovering
|
||||||
|
}
|
||||||
|
requestControlWindowSync(forceRefresh: true, delay: hovering ? 0.05 : 0)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
requestControlWindowSync(forceRefresh: true)
|
||||||
|
if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime {
|
||||||
|
triggerTransientLabel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
hideControlWindow()
|
||||||
|
cancelControlWindowSync()
|
||||||
|
cancelControlWindowSuppressionTask()
|
||||||
|
}
|
||||||
|
.onChange(of: timerManager.isTimerActive) { _, isActive in
|
||||||
|
if isActive {
|
||||||
|
if !timerManager.isFinished && !timerManager.isOvertime {
|
||||||
|
requestControlWindowSync(forceRefresh: true)
|
||||||
|
triggerTransientLabel()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hideControlWindow()
|
||||||
|
cancelTransientLabel()
|
||||||
|
showTransientLabel = false
|
||||||
|
isHovering = false
|
||||||
|
cancelControlWindowSync()
|
||||||
|
resetPendingControlWindowFlags()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: timerManager.timerName) { _, _ in
|
||||||
|
if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime {
|
||||||
|
requestControlWindowSync(forceRefresh: true)
|
||||||
|
triggerTransientLabel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: timerManager.isFinished) { _, finished in
|
||||||
|
if finished {
|
||||||
|
cancelTransientLabel()
|
||||||
|
withAnimation(.smooth) {
|
||||||
|
showTransientLabel = true
|
||||||
|
isHovering = false
|
||||||
|
}
|
||||||
|
requestControlWindowSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: timerManager.isOvertime) { _, overtime in
|
||||||
|
if overtime {
|
||||||
|
cancelTransientLabel()
|
||||||
|
withAnimation(.smooth) {
|
||||||
|
showTransientLabel = true
|
||||||
|
isHovering = false
|
||||||
|
}
|
||||||
|
requestControlWindowSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: timerManager.isPaused) { _, _ in
|
||||||
|
requestControlWindowSync(forceRefresh: true)
|
||||||
|
}
|
||||||
|
.onChange(of: controlWindowSyncKey) { _, _ in
|
||||||
|
requestControlWindowSync(forceRefresh: true)
|
||||||
|
}
|
||||||
|
.onChange(of: vm.notchState) { _, state in
|
||||||
|
handleControlWindowNotchStateChange(state)
|
||||||
|
}
|
||||||
|
.onChange(of: lockScreenManager.isLocked) { _, locked in
|
||||||
|
handleControlWindowLockStateChange(locked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var baseTimerLayout: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
|
leftWingView()
|
||||||
|
middleSectionView()
|
||||||
|
rightWingView()
|
||||||
|
}
|
||||||
|
.frame(height: adjustedNotchHeight, alignment: .center)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func leftWingView() -> some View {
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(width: leftWingWidth, height: notchContentHeight)
|
.frame(width: leftWingWidth, height: notchContentHeight)
|
||||||
.background(alignment: .leading) {
|
.background(alignment: .leading) {
|
||||||
|
|
@ -189,11 +329,17 @@ struct TimerLiveActivity: View {
|
||||||
.padding(.leading, wingPadding / 2)
|
.padding(.leading, wingPadding / 2)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func middleSectionView() -> some View {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(.black)
|
.fill(.black)
|
||||||
.frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0), height: notchContentHeight)
|
.frame(width: middleSectionWidth, height: notchContentHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func rightWingView() -> some View {
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(width: rightWingWidth, height: notchContentHeight)
|
.frame(width: rightWingWidth, height: notchContentHeight)
|
||||||
.background(alignment: .trailing) {
|
.background(alignment: .trailing) {
|
||||||
|
|
@ -209,96 +355,6 @@ struct TimerLiveActivity: View {
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onHover { hovering in
|
|
||||||
withAnimation(.smooth(duration: 0.18)) {
|
|
||||||
isHovering = hovering
|
|
||||||
}
|
|
||||||
syncControlWindow(forceRefresh: true)
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
syncControlWindow(forceRefresh: true)
|
|
||||||
if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime {
|
|
||||||
triggerTransientLabel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
hideControlWindow()
|
|
||||||
}
|
|
||||||
.onChange(of: timerManager.isTimerActive) { _, isActive in
|
|
||||||
if isActive {
|
|
||||||
if !timerManager.isFinished && !timerManager.isOvertime {
|
|
||||||
syncControlWindow(forceRefresh: true)
|
|
||||||
triggerTransientLabel()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
hideControlWindow()
|
|
||||||
cancelTransientLabel()
|
|
||||||
showTransientLabel = false
|
|
||||||
isHovering = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: timerManager.timerName) { _, _ in
|
|
||||||
if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime {
|
|
||||||
syncControlWindow(forceRefresh: true)
|
|
||||||
triggerTransientLabel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: timerManager.isFinished) { _, finished in
|
|
||||||
if finished {
|
|
||||||
cancelTransientLabel()
|
|
||||||
withAnimation(.smooth) {
|
|
||||||
showTransientLabel = true
|
|
||||||
isHovering = false
|
|
||||||
}
|
|
||||||
syncControlWindow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: timerManager.isOvertime) { _, overtime in
|
|
||||||
if overtime {
|
|
||||||
cancelTransientLabel()
|
|
||||||
withAnimation(.smooth) {
|
|
||||||
showTransientLabel = true
|
|
||||||
isHovering = false
|
|
||||||
}
|
|
||||||
syncControlWindow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: timerManager.isPaused) { _, _ in
|
|
||||||
syncControlWindow(forceRefresh: true)
|
|
||||||
}
|
|
||||||
.onChange(of: vm.closedNotchSize) { _, _ in
|
|
||||||
syncControlWindow(forceRefresh: true)
|
|
||||||
}
|
|
||||||
.onChange(of: vm.screen) { _, _ in
|
|
||||||
syncControlWindow(forceRefresh: true)
|
|
||||||
}
|
|
||||||
.onChange(of: vm.hideOnClosed) { _, _ in
|
|
||||||
syncControlWindow(forceRefresh: true)
|
|
||||||
}
|
|
||||||
.onChange(of: controlWindowEnabled) { _, _ in
|
|
||||||
syncControlWindow(forceRefresh: true)
|
|
||||||
}
|
|
||||||
.onChange(of: showsLabel) { _, _ in
|
|
||||||
syncControlWindow(forceRefresh: true)
|
|
||||||
}
|
|
||||||
.onChange(of: showTransientLabel) { _, _ in
|
|
||||||
syncControlWindow(forceRefresh: true)
|
|
||||||
}
|
|
||||||
.onChange(of: showsCountdown) { _, _ in
|
|
||||||
syncControlWindow(forceRefresh: true)
|
|
||||||
}
|
|
||||||
.onChange(of: showsProgress) { _, _ in
|
|
||||||
syncControlWindow(forceRefresh: true)
|
|
||||||
}
|
|
||||||
.onChange(of: progressStyle) { _, _ in
|
|
||||||
syncControlWindow(forceRefresh: true)
|
|
||||||
}
|
|
||||||
.onChange(of: timerManager.activeSource) { _, _ in
|
|
||||||
syncControlWindow(forceRefresh: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var iconSection: some View {
|
private var iconSection: some View {
|
||||||
let baseDiameter = ringWrapsIcon ? iconWidth : iconWidth
|
let baseDiameter = ringWrapsIcon ? iconWidth : iconWidth
|
||||||
|
|
@ -423,6 +479,139 @@ struct TimerLiveActivity: View {
|
||||||
height: notchContentHeight, alignment: .center)
|
height: notchContentHeight, alignment: .center)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func requestControlWindowSync(forceRefresh: Bool = false, delay: TimeInterval = 0) {
|
||||||
|
enqueueControlWindowSync(forceRefresh: forceRefresh, delay: delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enqueueControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0) {
|
||||||
|
if shouldDeferControlWindowSync() {
|
||||||
|
hasPendingControlWindowSync = true
|
||||||
|
if forceRefresh {
|
||||||
|
pendingControlWindowForceRefresh = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleControlWindowSync(forceRefresh: forceRefresh, delay: delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0, bypassSuppression: Bool = false) {
|
||||||
|
cancelControlWindowSync()
|
||||||
|
|
||||||
|
guard shouldShowControlWindow else {
|
||||||
|
resetPendingControlWindowFlags()
|
||||||
|
hideControlWindow()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bypassSuppression && shouldDeferControlWindowSync() {
|
||||||
|
hasPendingControlWindowSync = true
|
||||||
|
if forceRefresh {
|
||||||
|
pendingControlWindowForceRefresh = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetPendingControlWindowFlags()
|
||||||
|
|
||||||
|
let syncDelay = max(0, delay)
|
||||||
|
|
||||||
|
pendingControlWindowTask = Task.detached(priority: .userInitiated) { [syncDelay, forceRefresh] in
|
||||||
|
if syncDelay > 0 {
|
||||||
|
let nanoseconds = UInt64(syncDelay * 1_000_000_000)
|
||||||
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
if shouldShowControlWindow {
|
||||||
|
syncControlWindow(forceRefresh: forceRefresh)
|
||||||
|
} else {
|
||||||
|
hideControlWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingControlWindowTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelControlWindowSync() {
|
||||||
|
pendingControlWindowTask?.cancel()
|
||||||
|
pendingControlWindowTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetPendingControlWindowFlags() {
|
||||||
|
hasPendingControlWindowSync = false
|
||||||
|
pendingControlWindowForceRefresh = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldDeferControlWindowSync() -> Bool {
|
||||||
|
vm.notchState != .closed || lockScreenManager.isLocked || isControlWindowSuppressed
|
||||||
|
}
|
||||||
|
|
||||||
|
private func triggerPendingControlWindowSyncIfNeeded() {
|
||||||
|
guard hasPendingControlWindowSync else { return }
|
||||||
|
let shouldForce = pendingControlWindowForceRefresh
|
||||||
|
resetPendingControlWindowFlags()
|
||||||
|
scheduleControlWindowSync(forceRefresh: shouldForce, delay: 0, bypassSuppression: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func suppressControlWindowUpdates() {
|
||||||
|
isControlWindowSuppressed = true
|
||||||
|
cancelControlWindowSuppressionTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func releaseControlWindowUpdates(after delay: TimeInterval) {
|
||||||
|
cancelControlWindowSuppressionTask()
|
||||||
|
controlWindowSuppressionTask = Task { [delay] in
|
||||||
|
if delay > 0 {
|
||||||
|
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||||
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
if vm.notchState == .closed && !lockScreenManager.isLocked {
|
||||||
|
isControlWindowSuppressed = false
|
||||||
|
triggerPendingControlWindowSyncIfNeeded()
|
||||||
|
} else {
|
||||||
|
isControlWindowSuppressed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
controlWindowSuppressionTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelControlWindowSuppressionTask() {
|
||||||
|
controlWindowSuppressionTask?.cancel()
|
||||||
|
controlWindowSuppressionTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleControlWindowNotchStateChange(_ state: NotchState) {
|
||||||
|
if state == .open {
|
||||||
|
suppressControlWindowUpdates()
|
||||||
|
cancelControlWindowSync()
|
||||||
|
hideControlWindow()
|
||||||
|
} else {
|
||||||
|
releaseControlWindowUpdates(after: controlWindowResumeDelay)
|
||||||
|
requestControlWindowSync(forceRefresh: true, delay: 0.05)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleControlWindowLockStateChange(_ locked: Bool) {
|
||||||
|
if locked {
|
||||||
|
suppressControlWindowUpdates()
|
||||||
|
cancelControlWindowSync()
|
||||||
|
hideControlWindow()
|
||||||
|
} else {
|
||||||
|
releaseControlWindowUpdates(after: controlWindowResumeDelay)
|
||||||
|
requestControlWindowSync(forceRefresh: true, delay: 0.05)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func triggerTransientLabel() {
|
private func triggerTransientLabel() {
|
||||||
guard !showsLabel else { return }
|
guard !showsLabel else { return }
|
||||||
cancelTransientLabel()
|
cancelTransientLabel()
|
||||||
|
|
@ -433,7 +622,7 @@ struct TimerLiveActivity: View {
|
||||||
withAnimation(.smooth) {
|
withAnimation(.smooth) {
|
||||||
showTransientLabel = false
|
showTransientLabel = false
|
||||||
}
|
}
|
||||||
syncControlWindow(forceRefresh: true)
|
requestControlWindowSync(forceRefresh: true)
|
||||||
}
|
}
|
||||||
labelHideTask = task
|
labelHideTask = task
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + transientLabelDuration, execute: task)
|
DispatchQueue.main.asyncAfter(deadline: .now() + transientLabelDuration, execute: task)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue