diff --git a/Example/OpenSwiftUIUITests/View/ProgressViewUITests.swift b/Example/OpenSwiftUIUITests/View/ProgressViewUITests.swift new file mode 100644 index 000000000..ebd731ba4 --- /dev/null +++ b/Example/OpenSwiftUIUITests/View/ProgressViewUITests.swift @@ -0,0 +1,33 @@ +// +// ProgressViewUITests.swift +// OpenSwiftUIUITests + +import SnapshotTesting +import Testing +@testable import TestingHost + +@MainActor +@Suite(.snapshots(record: .never, diffTool: diffTool)) +struct ProgressViewUITests { + @Test + func valueBasedProgress() { + openSwiftUIAssertSnapshot(of: ProgressViewExample()) + } + + @Test + func indeterminateInitializers() { + openSwiftUIAssertSnapshot(of: IndeterminateProgressViewExample()) + } + + @Test(.disabled("ResolvableTextSegmentAttribute is not implemented yet")) + func defaultDateProgressLabelInitializers() { + openSwiftUIAssertSnapshot(of: DefaultDateProgressLabelExample()) + } + + @Test( + .disabled(if: attributeGraphVendor == .compute, "Temporarily disabled for IAG snapshot crash") + ) + func foundationProgress() { + openSwiftUIAssertSnapshot(of: FoundationProgressViewExample()) + } +} diff --git a/Example/Shared/View/ProgressViewExample.swift b/Example/Shared/View/ProgressViewExample.swift new file mode 100644 index 000000000..a35ed7264 --- /dev/null +++ b/Example/Shared/View/ProgressViewExample.swift @@ -0,0 +1,79 @@ +// +// ProgressViewExample.swift +// Shared + +import Foundation +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif + +struct ProgressViewExample: View { + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ProgressView(value: 0.25) + ProgressView("Downloading", value: 0.5) + ProgressView(value: 0.75) { + Text("Installing") + } currentValueLabel: { + Text("75%") + } + .tint(.blue) + } + .padding() + } +} + +struct IndeterminateProgressViewExample: View { + private let localizedTitle: LocalizedStringKey = "Localized label" + private let title = "String label" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ProgressView() + ProgressView { + Text("Custom label") + } + ProgressView(localizedTitle) + ProgressView(title) + } + .padding() + } +} + +struct DefaultDateProgressLabelExample: View { + private let timerInterval = Date(timeIntervalSinceReferenceDate: 0)...Date( + timeIntervalSinceReferenceDate: 90 + ) + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ProgressView(timerInterval: timerInterval) { + Text("Countdown") + } + ProgressView( + timerInterval: timerInterval, + countsDown: false + ) + } + .padding() + } +} + +struct FoundationProgressViewExample: View { + private let progress: Progress + + init() { + let progress = Progress(totalUnitCount: 100) + progress.completedUnitCount = 40 + progress.localizedDescription = "Downloading" + progress.localizedAdditionalDescription = "40%" + self.progress = progress + } + + var body: some View { + ProgressView(progress) + .padding() + } +} diff --git a/Sources/COpenSwiftUI/Shims/AppKit/AppKit_Private.h b/Sources/COpenSwiftUI/Shims/AppKit/AppKit_Private.h index 9c9ae9e69..37ef98f54 100644 --- a/Sources/COpenSwiftUI/Shims/AppKit/AppKit_Private.h +++ b/Sources/COpenSwiftUI/Shims/AppKit/AppKit_Private.h @@ -36,6 +36,10 @@ typedef OPENSWIFTUI_ENUM(NSInteger, NSViewVibrantBlendingStyle) { - (nullable NSAppearance *)appearanceByApplyingTintColor:(NSColor *)tintColor; @end +@interface NSProgressIndicator (OpenSwiftUI_SPI) +@property (nullable, strong) NSFont *font; +@end + @interface NSMenu (OpenSwiftUI_SPI) + (void)_setAlwaysCallDelegateBeforeSidebandUpdaters_openswiftui_safe_wrapper:(BOOL)value OPENSWIFTUI_SWIFT_NAME(_setAlwaysCallDelegateBeforeSidebandUpdaters(_:)); + (void)_setAlwaysInstallWindowTabItems_openswiftui_safe_wrapper:(BOOL)value OPENSWIFTUI_SWIFT_NAME(_setAlwaysInstallWindowTabItems(_:)); diff --git a/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h b/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h index f242c9051..51c517e83 100644 --- a/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h +++ b/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h @@ -48,6 +48,10 @@ OPENSWIFTUI_ASSUME_NONNULL_BEGIN @property(class, nonatomic, readonly) NSInteger _currentAnimationCurve_openswiftui_safe_wrapper OPENSWIFTUI_SWIFT_NAME(_currentAnimationCurve); @end +@interface UIActivityIndicatorView (OpenSwiftUI_SPI) +- (void)_setCustomWidth:(CGFloat)width; +@end + @interface UIResponder (OpenSwiftUI_SPI) - (void)_performMainMenuShortcutKeyCommand:(UIKeyCommand *)keyCommand; // FIXME @end diff --git a/Sources/OpenSwiftUI/Accessibility/AccessibilityGeometry.swift b/Sources/OpenSwiftUI/Accessibility/AccessibilityGeometry.swift new file mode 100644 index 000000000..287d9f661 --- /dev/null +++ b/Sources/OpenSwiftUI/Accessibility/AccessibilityGeometry.swift @@ -0,0 +1,33 @@ +// +// AccessibilityGeometry.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: WIP +// ID: EE68159C4F54001FA5A3813EBA5DD945 (SwiftUI) + +@_spi(Private) +import OpenSwiftUICore + +// MARK: - IgnoreViewRespondersModifier + +private struct IgnoreViewRespondersModifier: PrimitiveViewModifier, ViewInputsModifier { + nonisolated static func _makeViewInputs( + modifier _: _GraphValue, + inputs: inout _ViewInputs + ) { + inputs.needsAccessibilityViewResponders = false + } +} + +// MARK: - AccessibilityProgressViewModifier [WIP] + +struct AccessibilityProgressViewModifier { + var fractionCompleted: Double? + + func body(content: Content) -> some View where Content: View { + content + .modifier(IgnoreViewRespondersModifier()) + // .modifier(AccessibilityAttachmentModifier()) + } +} diff --git a/Sources/OpenSwiftUI/Data/Environment/PlatformEnvironment.swift b/Sources/OpenSwiftUI/Data/Environment/PlatformEnvironment.swift index 691c26b24..f90898f8e 100644 --- a/Sources/OpenSwiftUI/Data/Environment/PlatformEnvironment.swift +++ b/Sources/OpenSwiftUI/Data/Environment/PlatformEnvironment.swift @@ -125,19 +125,7 @@ struct OpenSwiftUIFallbackFontProvider: FallbackFontProvider { extension Font { static func _system(controlSize: ControlSize) -> Font { - let nsControlSize: NSControl.ControlSize = switch controlSize { - case .mini: .mini - case .small: .small - case .regular: .regular - case .large: .large - case .extraLarge: - if #available(macOS 26, *) { - .extraLarge - } else { - .large - } - } - let size = NSFont.systemFontSize(for: nsControlSize) + let size = NSFont.systemFontSize(for: NSControl.ControlSize(controlSize)) return Font.system(size: size) } } diff --git a/Sources/OpenSwiftUI/Integration/Graphic/AppKit/AppKitConversions.swift b/Sources/OpenSwiftUI/Integration/Graphic/AppKit/AppKitConversions.swift new file mode 100644 index 000000000..b6310ecc2 --- /dev/null +++ b/Sources/OpenSwiftUI/Integration/Graphic/AppKit/AppKitConversions.swift @@ -0,0 +1,34 @@ +// +// AppKitConversions.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: WIP + +#if canImport(AppKit) +import AppKit +import OpenSwiftUICore + +// MARK: - ControlSize Conversions + +extension NSControl.ControlSize { + init(_ controlSize: ControlSize) { + switch controlSize { + case .mini: + self = .mini + case .small: + self = .small + case .regular: + self = .regular + case .large: + self = .large + case .extraLarge: + if #available(macOS 26.0, *) { + self = .extraLarge + } else { + self = .large + } + } + } +} +#endif diff --git a/Sources/OpenSwiftUI/View/ArchivedView/ArchivableView.swift b/Sources/OpenSwiftUI/View/ArchivedView/ArchivableView.swift new file mode 100644 index 000000000..6dfbb7399 --- /dev/null +++ b/Sources/OpenSwiftUI/View/ArchivedView/ArchivableView.swift @@ -0,0 +1,21 @@ +// +// ArchivableView.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Blocked by ArchivableFactory + +public import Foundation +public import OpenSwiftUICore + +// MARK: - _ArchivableView [WIP] + +protocol _ArchivableView: Codable, View { + func sizeThatFits(in proposedSize: _ProposedSize) -> CGSize +} + +extension _ArchivableView { + func sizeThatFits(in proposedSize: _ProposedSize) -> CGSize { + proposedSize.fixingUnspecifiedDimensions() + } +} diff --git a/Sources/OpenSwiftUI/View/Control/ProgressView/AppKitProgressView.swift b/Sources/OpenSwiftUI/View/Control/ProgressView/AppKitProgressView.swift new file mode 100644 index 000000000..e497e8949 --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/ProgressView/AppKitProgressView.swift @@ -0,0 +1,108 @@ +// +// AppKitProgressView.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +#if os(macOS) +import AppKit +import COpenSwiftUI +import OpenSwiftUICore + +// MARK: - AppKitProgressView + +struct AppKitProgressView: NSViewRepresentable { + var fractionCompleted: Double? + var style: NSProgressIndicator.Style + var tint: Color? + + @Environment(\.controlSize) + private var controlSize + + @Environment(\.effectiveFont) + private var font + + func makeNSView(context _: Context) -> NSProgressIndicator { + let nsView = NSProgressIndicator() + nsView.minValue = 0 + nsView.maxValue = 1 + return nsView + } + + func updateNSView(_ nsView: NSProgressIndicator, context: Context) { + nsView.style = style + if fractionCompleted == nil { + nsView.startAnimation(nil) + } else { + nsView.stopAnimation(nil) + } + nsView.isIndeterminate = fractionCompleted == nil + nsView.doubleValue = fractionCompleted ?? 0 + + let controlSize = NSControl.ControlSize(controlSize) + if nsView.controlSize != controlSize { + nsView.controlSize = controlSize + } + + nsView.font = font.platformFont(in: context.environment) + if let superview = nsView.superview { + let appearance = superview.effectiveAppearance + nsView.appearance = if let tint, tint != Color.accentColor { + appearance.applyingTintColor(.init(tint)) + } else { + nil + } + } + } +} + +// MARK: - LinearAppKitProgressView + +struct LinearAppKitProgressView: View { + var configuration: ProgressViewStyleConfiguration + var tint: Color? + + var body: some View { + switch configuration.value { + case let .absolute(fractionCompleted, _): + Base( + fractionCompleted: fractionCompleted, + tint: tint + ) + case let .dateRelative(interval, countdown): + TimelineProgressView( + interval: interval, + updateStyle: .default, + countdown: countdown, + tint: tint, + isCircular: false, + extendedState: .init() + ) + } + } + + struct Base: TimelineProgressViewBase { + var fractionCompleted: Double? + var tint: Color? + + init(fractionCompleted: Double?, tint: Color?) { + self.fractionCompleted = fractionCompleted + self.tint = tint + } + + init(fractionCompleted: Double, tint: Color?) { + self.fractionCompleted = fractionCompleted + self.tint = tint + } + + var body: some View { + AppKitProgressView( + fractionCompleted: fractionCompleted, + style: .bar, + tint: tint + ) + } + } +} +#endif diff --git a/Sources/OpenSwiftUI/View/Control/ProgressView/ArchivableProgressView.swift b/Sources/OpenSwiftUI/View/Control/ProgressView/ArchivableProgressView.swift new file mode 100644 index 000000000..f51f065ee --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/ProgressView/ArchivableProgressView.swift @@ -0,0 +1,225 @@ +// +// ArchivableProgressView.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +public import Foundation +import OpenSwiftUICore + +// MARK: - ArchivableCircularPlaybackProgressView + +struct ArchivableCircularPlaybackProgressView: View { + var configuration: ProgressViewStyleConfiguration + var tint: Color + + var body: some View { + ResolvedCircularPlaybackProgressView( + configuration: configuration, + tint: tint + ) + } +} + +// MARK: - ArchivableCircularProgressView + +struct ArchivableCircularProgressView: View { + var size: CGFloat + var centerFont: CGFloat + var configuration: ProgressViewStyleConfiguration + var tint: Color? + struct Metrics {} + let metrics: Metrics + + @ViewBuilder + var gaugeRing: some View { + switch configuration.value { + case let .absolute(fractionCompleted, _): + CircularPercentageGaugeRing( + fractionCompleted: fractionCompleted ?? 0, + tint: tint + ) + case let .dateRelative(interval, countdown): + TimelineProgressView( + interval: interval, + updateStyle: .default, + countdown: countdown, + tint: tint, + isCircular: true, + extendedState: .init() + ) + } + } + + var body: some View { + GeometryReader { proxy in + ZStack { + gaugeRing + VStack { + if configuration.currentValueLabel != nil { + HStack { + configuration.currentValueLabel + } + } else { + HStack { + configuration.label + } + } + } + .font( + .system( + size: centerFont * proxy.size.height / size, + weight: .semibold, + design: .rounded + ) + ) + .frame(height: min(proxy.size.width, proxy.size.height) * 0.5) + .labelStyle(.iconOnly) + .modifier(_ForegroundLayerViewModifier()) + .multilineTextAlignment(.center) + } + } + .aspectRatio(1, contentMode: .fit) + .minimumScaleFactor(0.01) + } +} + +// MARK: - ArchivableLinearProgressView + +struct ArchivableLinearProgressView: View { + var configuration: ProgressViewStyleConfiguration + var tint: Color? + + @ViewBuilder + var body: some View { + switch configuration.value { + case let .absolute(fractionCompleted, _): + Base( + fractionCompleted: fractionCompleted ?? 0, + tint: tint + ) + case let .dateRelative(interval, countdown): + TimelineProgressView( + interval: interval, + updateStyle: .default, + countdown: countdown, + tint: tint, + isCircular: false, + extendedState: .init() + ) + } + } + + struct Base: TimelineProgressViewBase { + var fractionCompleted: Double + var tint: Color? + + var body: some View { + LinearCapsuleGauge(value: fractionCompleted) + // TODO + } + } +} + +// MARK: - ResolvedCircularPlaybackProgressView + +struct ResolvedCircularPlaybackProgressView: View { + var configuration: ProgressViewStyleConfiguration + var tint: Color + + var body: some View { + switch configuration.value { + case let .absolute(fractionCompleted, _): + Base( + fractionCompleted: fractionCompleted ?? 0, + tint: tint + ) + case let .dateRelative(interval, _): + TimelineProgressView( + interval: interval, + updateStyle: .default, + countdown: false, + tint: tint, + isCircular: true, + extendedState: .init() + ) + } + } + + struct Base: TimelineProgressViewBase { + var fractionCompleted: Double + var tint: Color + + init(fractionCompleted: Double, tint: Color?) { + self.fractionCompleted = fractionCompleted + self.tint = tint ?? .accentColor + } + + var body: some View { + Circle() + .inset(by: 2.0) + .trim(from: 0, to: fractionCompleted) + .stroke(tint, lineWidth: 4.0) + .rotationEffect(.degrees(-90.0)) + } + } +} + +// MARK: - CircularPercentageGaugeRing [TODO] + +struct CircularPercentageGaugeRing: TimelineProgressViewBase { + var fractionCompleted: Double + var tint: AnyShapeStyle + + init(fractionCompleted: Double, tint: Color?) { + self.fractionCompleted = fractionCompleted + self.tint = AnyShapeStyle(tint ?? .primary) + } + + var body: some View { + EmptyView() + } +} + +// MARK: - LinearCapsuleGauge [TODO] + +struct LinearCapsuleGauge: View { + var value: Double + // @ScaledMetric var height: CGFloat + // @Environment(\.) var gaugeTintOverride: (Color, Color)? + // @Environment(\.) var controlTint: AnyShapeStyle? + // @Environment(\.) var direction: LayoutDirection? + + var body: some View { + EmptyView() + } +} + +extension Shape { + @inlinable + nonisolated public func trim( + from startFraction: CGFloat = 0, + to endFraction: CGFloat = 1 + ) -> some Shape { + // FIXME: _TrimmedShape + self + } +} + +// FIXME +typealias _ForegroundLayerViewModifier = EmptyModifier + +extension View { + func labelStyle(_ style: LabelStyle) -> some View { + self + } +} + +protocol LabelStyle {} + +struct IconOnlyLabelStyle: LabelStyle {} + +extension LabelStyle where Self == IconOnlyLabelStyle { + static var iconOnly: IconOnlyLabelStyle { .init() } +} diff --git a/Sources/OpenSwiftUI/View/Control/ProgressView/CircularProgressViewStyle.swift b/Sources/OpenSwiftUI/View/Control/ProgressView/CircularProgressViewStyle.swift new file mode 100644 index 000000000..2d9748602 --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/ProgressView/CircularProgressViewStyle.swift @@ -0,0 +1,137 @@ +// +// CircularProgressViewStyle.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +@_spi(ForOpenSwiftUIOnly) +public import OpenSwiftUICore + +// MARK: - ProgressViewStyle + Circular + +@available(OpenSwiftUI_v2_0, *) +extension ProgressViewStyle where Self == CircularProgressViewStyle { + /// A progress view that uses a circular gauge to indicate the partial + /// completion of an activity. + @_alwaysEmitIntoClient + @MainActor + @preconcurrency + public static var circular: CircularProgressViewStyle { + .init() + } +} + +// MARK: - CircularProgressViewStyle + +/// A progress view that uses a circular gauge to indicate the partial +/// completion of an activity. +@available(OpenSwiftUI_v2_0, *) +public struct CircularProgressViewStyle: ProgressViewStyle { + @Environment(\.tintColor) + private var controlTint: Color? + + @Environment(\.labelsVisibility) + private var labelsVisibility: Visibility + + private let tint: Color? + + @_spi(Private) + @available(iOS, unavailable) + @available(macOS, unavailable) + @available(tvOS, unavailable) + @available(watchOS, deprecated: 8.0, message: "Use View.controlSize(_:) instead.") + public enum Size: Hashable { + case small + case medium + case large + } + + /// Creates a circular progress view style. + public init() { + tint = nil + } + + @_spi(Private) + @available(iOS, unavailable) + @available(macOS, unavailable) + @available(tvOS, unavailable) + @available(watchOS, deprecated: 8.0, message: "Use View.controlSize(_:) instead.") + public init(size _: Size) { + tint = nil + } + + /// Creates a circular progress view style with a custom tint color. + @available(*, deprecated, message: "Use ``View/tint(_)`` instead.") + public init(tint: Color) { + self.tint = tint + } + + public func makeBody(configuration: Configuration) -> some View { + VStack { + fractionCompletedView(configuration: configuration) + StaticIf(idiom: .widget) { + configuration.alwaysIndeterminate + ? labels(configuration: configuration) + : nil + } else: { + labels(configuration: configuration) + } + } + .spacing(Spacing()) + } + + @ViewBuilder + func fractionCompletedView(configuration: Configuration) -> some View { + StaticIf(idiom: .widget) { + ArchivableCircularProgressView( + size: 58, + centerFont: 30, + configuration: configuration, + tint: tint ?? controlTint, + metrics: .init() + ) + } else: { + #if os(macOS) + AppKitProgressView( + fractionCompleted: configuration.fractionCompleted, + style: .spinning, + tint: tint ?? controlTint + ) + #elseif os(iOS) || os(visionOS) + StaticIf(idiom: MacInterfaceIdiom.mac) { + CircularUIKitProgressView( + tint: tint ?? controlTint, + useCustomWidth: false + ) + } else: { + CircularUIKitProgressView( + tint: tint ?? controlTint, + useCustomWidth: true + ) + } + #else + _openSwiftUIPlatformUnimplementedFailure() + #endif + } + } + + @ViewBuilder + func labels(configuration: Configuration) -> some View { + if !isLinkedOnOrAfter(.v5) || labelsVisibility != .hidden { + VStack { + HStack { + configuration.label + } + HStack { + configuration.currentValueLabel + } + .font(.caption) + } + .defaultForegroundColor(.secondary) + } + } +} + +@available(*, unavailable) +extension CircularProgressViewStyle: Sendable {} diff --git a/Sources/OpenSwiftUI/View/Control/ProgressView/DefaultProgressViewStyle.swift b/Sources/OpenSwiftUI/View/Control/ProgressView/DefaultProgressViewStyle.swift new file mode 100644 index 000000000..9457fb9c5 --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/ProgressView/DefaultProgressViewStyle.swift @@ -0,0 +1,67 @@ +// +// DefaultProgressViewStyle.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +public import OpenSwiftUICore + +// MARK: - ProgressViewStyle + Automatic + +@available(OpenSwiftUI_v2_0, *) +extension ProgressViewStyle where Self == DefaultProgressViewStyle { + + /// The default progress view style in the current context of the view being + /// styled. + /// + /// The default style represents the recommended style based on the original + /// initialization parameters of the progress view, and the progress view's + /// context within the view hierarchy. + @_alwaysEmitIntoClient + @MainActor + @preconcurrency + public static var automatic: DefaultProgressViewStyle { + .init() + } +} + +// MARK: - DefaultProgressViewStyle + +/// The default progress view style in the current context of the view being +/// styled. +/// +/// Use ``ProgressViewStyle/automatic`` to construct this style. +@available(OpenSwiftUI_v2_0, *) +public struct DefaultProgressViewStyle: ProgressViewStyle { + /// Creates a default progress view style. + public init() { + _openSwiftUIEmptyStub() + } + + public func makeBody(configuration: Configuration) -> some View { + Group { + if configuration.alwaysIndeterminate { + ProgressView(configuration) + .progressViewStyle(CircularProgressViewStyle()) + } else { + switch configuration.value { + case .dateRelative: + ProgressView(configuration) + .progressViewStyle(LinearProgressViewStyle()) + case .absolute: + if configuration.fractionCompleted != nil { + ProgressView(configuration) + .progressViewStyle(LinearProgressViewStyle()) + } else { + ProgressView(configuration) + .progressViewStyle(CircularProgressViewStyle()) + } + } + } + } + } +} + +@available(*, unavailable) +extension DefaultProgressViewStyle: Sendable {} diff --git a/Sources/OpenSwiftUI/View/Control/ProgressView/LinearProgressViewStyle.swift b/Sources/OpenSwiftUI/View/Control/ProgressView/LinearProgressViewStyle.swift new file mode 100644 index 000000000..9f54204fd --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/ProgressView/LinearProgressViewStyle.swift @@ -0,0 +1,90 @@ +// +// LinearProgressViewStyle.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: 6399EAC5515CA9566698FD9D51220283 (SwiftUI) + +public import OpenSwiftUICore + +// MARK: - ProgressViewStyle + Linear + +@available(OpenSwiftUI_v2_0, *) +extension ProgressViewStyle where Self == LinearProgressViewStyle { + /// A progress view that visually indicates its progress using a horizontal + /// bar. + @_alwaysEmitIntoClient + @MainActor + @preconcurrency + public static var linear: LinearProgressViewStyle { + .init() + } +} + +// MARK: - LinearProgressViewStyle + +/// A progress view that visually indicates its progress using a horizontal +/// bar. +@available(OpenSwiftUI_v2_0, *) +public struct LinearProgressViewStyle: ProgressViewStyle { + @Environment(\.tintColor) + private var controlTint: Color? + + @Environment(\.labelsVisibility) + private var labelsVisibility: Visibility + + private let tint: Color? + + /// Creates a linear progress view style. + public init() { + tint = nil + } + + /// Creates a linear progress view style with a custom tint color. + @available(*, deprecated, message: "Use ``View/tint(_)`` instead.") + public init(tint: Color) { + self.tint = tint + } + + public func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading, spacing: 4) { + if !isLinkedOnOrAfter(.v5) || labelsVisibility != .hidden { + configuration.label + } + progressBar(configuration: configuration) + if !isLinkedOnOrAfter(.v5) || labelsVisibility != .hidden { + configuration.currentValueLabel + .defaultForegroundColor(.secondary) + .font(.caption) + .monospacedDigit() + } + } + } + + private func progressBar(configuration: Configuration) -> some View { + StaticIf(idiom: .widget) { + ArchivableLinearProgressView( + configuration: configuration, + tint: tint ?? controlTint + ) + } else: { + #if os(macOS) + LinearAppKitProgressView( + configuration: configuration, + tint: tint ?? controlTint + ) + #elseif os(iOS) || os(visionOS) + LinearUIKitProgressView( + configuration: configuration, + tint: tint ?? controlTint + ) + #else + _openSwiftUIPlatformUnimplementedFailure() + #endif + } + } +} + +@available(*, unavailable) +extension LinearProgressViewStyle: Sendable {} diff --git a/Sources/OpenSwiftUI/View/Control/ProgressView/ProgressView+Date.swift b/Sources/OpenSwiftUI/View/Control/ProgressView/ProgressView+Date.swift new file mode 100644 index 000000000..e2985a16c --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/ProgressView/ProgressView+Date.swift @@ -0,0 +1,334 @@ +// +// ProgressView+Date.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: E25B5CE50FE780022155187DDAA79ACA (SwiftUI) + +public import Foundation +@_spi(Private) +import OpenSwiftUICore + +// MARK: - DefaultDateProgressLabel + +/// The default current value label used by a date-relative progress view. +@available(OpenSwiftUI_v4_0, *) +public struct DefaultDateProgressLabel: View { + var interval: ClosedRange + var countdown: Bool + + public var body: some View { + Text( + timerInterval: interval, + countsDown: countdown + ) + } +} + +@available(*, unavailable) +extension DefaultDateProgressLabel: Sendable {} + +// MARK: - Date-Relative Initializers + +@available(OpenSwiftUI_v4_0, *) +extension ProgressView { + /// Creates a progress view that displays progress over a date interval. + nonisolated public init( + timerInterval: ClosedRange, + countsDown: Bool = true, + @ViewBuilder label: () -> Label, + @ViewBuilder currentValueLabel: () -> CurrentValueLabel + ) { + base = .custom( + CustomProgressView( + interval: timerInterval, + countdown: countsDown, + label: label(), + currentValueLabel: currentValueLabel() + ) + ) + } +} + +@_spi(_) +@available(OpenSwiftUI_v4_0, *) +extension ProgressView { + @available(*, deprecated, renamed: "init(timerInterval:countsDown:label:currentValueLabel:)") + nonisolated public init( + interval: ClosedRange, + countdown: Bool = true, + @ViewBuilder label: () -> Label, + @ViewBuilder currentValueLabel: () -> CurrentValueLabel + ) { + self.init( + timerInterval: interval, + countsDown: countdown, + label: label, + currentValueLabel: currentValueLabel + ) + } +} + +@available(OpenSwiftUI_v4_0, *) +extension ProgressView where CurrentValueLabel == DefaultDateProgressLabel { + /// Creates a progress view that displays progress over a date interval and + /// uses the default current value label. + nonisolated public init( + timerInterval: ClosedRange, + countsDown: Bool = true, + @ViewBuilder label: () -> Label + ) { + base = .custom( + CustomProgressView( + interval: timerInterval, + countdown: countsDown, + label: label(), + currentValueLabel: DefaultDateProgressLabel( + interval: timerInterval, + countdown: countsDown + ) + ) + ) + } +} + +@_spi(_) +@available(OpenSwiftUI_v4_0, *) +extension ProgressView where CurrentValueLabel == DefaultDateProgressLabel { + @_spi(_) + @available(*, deprecated, renamed: "init(timerInterval:countsDown:label:)") + nonisolated public init( + interval: ClosedRange, + countdown: Bool = true, + @ViewBuilder label: () -> Label + ) { + self.init( + timerInterval: interval, + countsDown: countdown, + label: label + ) + } +} + +@available(OpenSwiftUI_v4_0, *) +extension ProgressView where Label == EmptyView, CurrentValueLabel == DefaultDateProgressLabel { + /// Creates an unlabeled progress view that displays progress over a date + /// interval and uses the default current value label. + nonisolated public init( + timerInterval: ClosedRange, + countsDown: Bool = true + ) { + base = .custom( + CustomProgressView( + interval: timerInterval, + countdown: countsDown, + label: nil, + currentValueLabel: DefaultDateProgressLabel( + interval: timerInterval, + countdown: countsDown + ) + ) + ) + } +} + +@_spi(_) +@available(OpenSwiftUI_v4_0, *) +extension ProgressView where Label == EmptyView, CurrentValueLabel == DefaultDateProgressLabel { + @_spi(_) + @available(*, deprecated, renamed: "init(timerInterval:countsDown:)") + nonisolated public init( + interval: ClosedRange, + countdown: Bool = true + ) { + self.init( + timerInterval: interval, + countsDown: countdown + ) + } +} + +// MARK: - TimelineProgressViewExtendedBase + +protocol TimelineProgressViewExtendedBase: View { + associatedtype ExtendedState: Codable + + init(fractionCompleted: Double, tint: Color?, extendedState: ExtendedState) +} + +// MARK: - TimelineProgressViewBase + +protocol TimelineProgressViewBase: TimelineProgressViewExtendedBase where ExtendedState == _TimelineProgressViewBaseEmptyState { + init(fractionCompleted: Double, tint: Color?) +} + +extension TimelineProgressViewBase { + init(fractionCompleted: Double, tint: Color?, extendedState: ExtendedState) { + self.init(fractionCompleted: fractionCompleted, tint: tint) + } +} + +// MARK: - _TimelineProgressViewBaseEmptyState + +struct _TimelineProgressViewBaseEmptyState: Codable {} + +// MARK: - TimelineProgressView + +struct TimelineProgressView: View where Base: TimelineProgressViewExtendedBase { + var interval: ClosedRange + var updateStyle: TimelineProgressViewUpdateStyle + var countdown: Bool + var tint: Color? + var isCircular: Bool + var extendedState: Base.ExtendedState + + var body: some View { + ConditionallyArchivableTimelineProgressView( + interval: interval, + updateStyle: updateStyle, + countdown: countdown, + tint: tint, + isCircular: isCircular, + extendedState: extendedState + ) + } + + struct ArchivableTimelineProgressView: _ArchivableView { + var interval: ClosedRange + var updateStyle: TimelineProgressViewUpdateStyle + var countdown: Bool + var resolvedTint: Color.Resolved? + var extendedState: Base.ExtendedState + + var body: some View { + FinalTimelineProgressView( + interval: interval, + updateStyle: updateStyle, + countdown: countdown, + tint: resolvedTint.map(Color.init), + extendedState: extendedState + ) + } + } + + private struct ConditionallyArchivableTimelineProgressView: ConditionallyArchivableView { + var interval: ClosedRange + var updateStyle: TimelineProgressViewUpdateStyle + var countdown: Bool + var tint: Color? + var isCircular: Bool + var extendedState: Base.ExtendedState + + var body: some View { + FinalTimelineProgressView( + interval: interval, + updateStyle: updateStyle, + countdown: countdown, + tint: tint, + extendedState: extendedState + ) + } + + var archivedBody: some View { + EnvironmentReader { environment in + ArchivableTimelineProgressView( + interval: interval, + updateStyle: updateStyle, + countdown: countdown, + resolvedTint: tint?.resolve(in: environment), + extendedState: extendedState + ) + } + .fixedSize(horizontal: false, vertical: !isCircular) + } + } + + private struct FinalTimelineProgressView: View { + var interval: ClosedRange + var updateStyle: TimelineProgressViewUpdateStyle + var countdown: Bool + var tint: Color? + var extendedState: Base.ExtendedState + + @ViewBuilder + var body: some View { + TimelineView( + ProgressViewSchedule( + interval: interval, + updateStyle: updateStyle + ) + ) { context in + Base( + fractionCompleted: interval.progress( + at: context.date, + countdown: countdown + ), + tint: tint, + extendedState: extendedState + ) + } + } + } +} + +// MARK: - TimelineProgressViewUpdateStyle + +enum TimelineProgressViewUpdateStyle: Codable, Hashable { + case `default` + case onTheSecond +} + +// MARK: - ProgressViewSchedule + +@available(OpenSwiftUI_v3_0, *) +struct ProgressViewSchedule: TimelineSchedule { + var interval: ClosedRange + var updateStyle: TimelineProgressViewUpdateStyle + + func entries( + from _: Date, + mode: TimelineScheduleMode + ) -> AnyIterator { + let entries: AnySequence + switch mode { + case .normal: + switch updateStyle { + case .default: + entries = AnySequence( + AnimationTimelineSchedule() + .entries(from: interval.lowerBound, mode: mode) + ) + case .onTheSecond: + entries = AnySequence( + PeriodicTimelineSchedule(from: interval.lowerBound, by: 1) + .entries(from: interval.lowerBound, mode: mode) + ) + } + case .lowFrequency: + let calendar = Calendar.current + let second = calendar.component(.second, from: interval.upperBound) + let alignedStart = calendar.nextDate( + after: interval.lowerBound, + matching: DateComponents(second: second, nanosecond: 0), + matchingPolicy: .nextTime, + repeatedTimePolicy: .first, + direction: .backward + ) ?? interval.lowerBound + entries = AnySequence( + PeriodicTimelineSchedule(from: alignedStart, by: 60) + .entries(from: alignedStart, mode: mode) + ) + } + var iterator = entries.makeIterator() + return AnyIterator { + guard let date = iterator.next() else { + return nil + } + if date > interval.upperBound, Date.now >= interval.upperBound { + return .distantFuture + } + return date + } + } +} diff --git a/Sources/OpenSwiftUI/View/Control/ProgressView/ProgressView+Foundation.swift b/Sources/OpenSwiftUI/View/Control/ProgressView/ProgressView+Foundation.swift new file mode 100644 index 000000000..ccc66f3d2 --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/ProgressView/ProgressView+Foundation.swift @@ -0,0 +1,192 @@ +// +// ProgressView+Foundation.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: 3E94A30D8C602D81EB55934DA2DC2E22 (SwiftUI) + +#if OPENSWIFTUI_OPENCOMBINE +import OpenCombine +import OpenCombineFoundation +#else +import Combine +#endif +import Foundation +import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore + +// MARK: - Progress + UI State + +extension Foundation.Progress { + fileprivate struct UIState { + var fractionCompleted: Double + var isIndeterminate: Bool + var localizedDescription: String + var localizedAdditionalDescription: String + } + + fileprivate var uiState: UIState { + UIState( + fractionCompleted: fractionCompleted, + isIndeterminate: isIndeterminate, + localizedDescription: localizedDescription, + localizedAdditionalDescription: localizedAdditionalDescription + ) + } + + #if !OPENSWIFTUI_OPENCOMBINE + fileprivate typealias UIStatePublisher = Publishers.Map< + Publishers.CombineLatest4< + NSObject.KeyValueObservingPublisher, + NSObject.KeyValueObservingPublisher, + NSObject.KeyValueObservingPublisher, + NSObject.KeyValueObservingPublisher + >, + UIState + > + + fileprivate var uiStatePublisher: UIStatePublisher { + Publishers.CombineLatest4( + publisher( + for: \.completedUnitCount, + options: [.initial, .new] + ), + publisher( + for: \.totalUnitCount, + options: [.initial, .new] + ), + publisher( + for: \.localizedDescription, + options: [.initial, .new] + ), + publisher( + for: \.localizedAdditionalDescription, + options: [.initial, .new] + ) + ).map { [unowned self] _ in + uiState + } + } + #else + // TODO: Implement NSObject.KeyValueObservingPublisher in OpenCombine + fileprivate typealias UIStatePublisher = AnyPublisher + + fileprivate var uiStatePublisher: UIStatePublisher { + // [AI] OpenCombineFoundation does not provide KVO publishers. Sample + // Progress while the graph-owned subscription is active instead. + Foundation.Timer.publish( + every: 1.0 / 30.0, + on: .main, + in: .common + ) + .autoconnect() + .map { [unowned self] _ in + uiState + } + .eraseToAnyPublisher() + } + #endif + + fileprivate struct UIStateSubscriber: Subscriber, Cancellable { + @Binding var viewState: UIState? + var combineIdentifier = CombineIdentifier() + + func respond(to state: UIState) { + func update() { + viewState = state + } + + if Thread.isMainThread { + Update.enqueueAction(reason: nil, update) + } else { + update() + } + } + + func receive(subscription: any Subscription) { + subscription.request(.unlimited) + } + + func receive(_ input: UIState) -> Subscribers.Demand { + respond(to: input) + return .none + } + + func receive(completion _: Subscribers.Completion) { + _openSwiftUIEmptyStub() + } + + func cancel() { + _openSwiftUIEmptyStub() + } + } +} + +// MARK: - FoundationProgressView + +struct FoundationProgressView: View { + var progress: Foundation.Progress + @State private var state: Foundation.Progress.UIState? + + var body: Body { + Body(progress: progress, state: $state) + } + + struct Body: MultiView, PrimitiveView, View { + var progress: Foundation.Progress + + @Binding + fileprivate var state: Foundation.Progress.UIState? + + nonisolated static func _makeViewList( + view: _GraphValue, + inputs: _ViewListInputs + ) -> _ViewListOutputs { + let value = Attribute( + BodyAttribute( + view: view.value, + subscription: .init() + ) + ) + return BodyAttribute.Value._makeViewList( + view: _GraphValue(value), + inputs: inputs + ) + } + + private struct BodyAttribute: StatefulRule { + @Attribute var view: Body + var subscription: SubscriptionLifetime + + mutating func updateValue() { + let subscriber = Foundation.Progress.UIStateSubscriber(viewState: view.$state) + subscription.subscribe( + subscriber: subscriber, + to: view.progress.uiStatePublisher + ) + value = Value(state: view.state ?? view.progress.uiState) + } + + struct Value: View { + var state: Foundation.Progress.UIState + + var body: some View { + ResolvedProgressView( + value: .absolute( + fractionCompleted: state.isIndeterminate ? nil : state.fractionCompleted, + alwaysIndeterminate: false + ) + ) + .optionalViewAlias(ProgressViewStyleConfiguration.CurrentValueLabel.self) { + state.localizedAdditionalDescription.isEmpty ? nil : Text(state.localizedAdditionalDescription) + } + .optionalViewAlias(ProgressViewStyleConfiguration.Label.self) { + state.localizedDescription.isEmpty ? nil : Text(state.localizedDescription) + } + } + } + } + } +} diff --git a/Sources/OpenSwiftUI/View/Control/ProgressView/ProgressView.swift b/Sources/OpenSwiftUI/View/Control/ProgressView/ProgressView.swift new file mode 100644 index 000000000..944f428d6 --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/ProgressView/ProgressView.swift @@ -0,0 +1,728 @@ +// +// ProgressView.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: 936A47782A7E2FBE97D58CDBAEB02770 (SwiftUI) + +import OpenAttributeGraphShims +public import Foundation +@_spi(ForOpenSwiftUIOnly) +public import OpenSwiftUICore + +// MARK: - ProgressView + +/// A view that shows the progress toward completion of a task. +/// +/// Use a progress view to show that a task is incomplete but advancing toward +/// completion. A progress view can show both determinate (percentage complete) +/// and indeterminate (progressing or not) types of progress. +/// +/// Create a determinate progress view by initializing a `ProgressView` with +/// a binding to a numeric value that indicates the progress, and a `total` +/// value that represents completion of the task. By default, the progress is +/// `0.0` and the total is `1.0`. +/// +/// The example below uses the state property `progress` to show progress in +/// a determinate `ProgressView`. The progress view uses its default total of +/// `1.0`, and because `progress` starts with an initial value of `0.5`, +/// the progress view begins half-complete. A "More" button below the progress +/// view allows people to increment the progress in increments of five percent: +/// +/// struct LinearProgressDemoView: View { +/// @State private var progress = 0.5 +/// +/// var body: some View { +/// VStack { +/// ProgressView(value: progress) +/// Button("More") { progress += 0.05 } +/// } +/// } +/// } +/// +/// ![A horizontal bar that represents progress, with a More button +/// placed underneath. The progress bar is at 50 percent from the leading +/// edge.](ProgressView-1-macOS) +/// +/// To create an indeterminate progress view, use an initializer that doesn't +/// take a progress value: +/// +/// var body: some View { +/// ProgressView() +/// } +/// +/// ![An indeterminate progress view, presented as a spinning set of gray lines +/// emanating from the center of a circle, with opacity varying from fully +/// opaque to transparent. An animation rotates which line is most opaque, +/// creating the spinning effect.](ProgressView-2-macOS) +/// +/// You can also create a progress view that covers a closed range of +/// [Date](https://developer.apple.com/documentation/foundation/date) values. As +/// long as the current date is within the range, the progress view +/// automatically updates, filling or depleting the progress view as it nears +/// the end of the range. The following example shows a five-minute timer whose +/// start time is that of the progress view's initialization: +/// +/// struct DateRelativeProgressDemoView: View { +/// let workoutDateRange = Date()...Date().addingTimeInterval(5*60) +/// +/// var body: some View { +/// ProgressView(timerInterval: workoutDateRange) { +/// Text("Workout") +/// } +/// } +/// } +/// +/// ![A horizontal progress view that shows a bar partially filled with as it +/// counts a five-minute duration.](ProgressView-3-macOS) +/// +/// ### Styling progress views +/// +/// You can customize the appearance and interaction of progress views by +/// creating styles that conform to the ``ProgressViewStyle`` protocol. To set a +/// specific style for all progress view instances within a view, use the +/// ``View/progressViewStyle(_:)`` modifier. In the following example, a custom +/// style adds a rounded pink border to all progress views within the enclosing +/// ``VStack``: +/// +/// struct BorderedProgressViews: View { +/// var body: some View { +/// VStack { +/// ProgressView(value: 0.25) { Text("25% progress") } +/// ProgressView(value: 0.75) { Text("75% progress") } +/// } +/// .progressViewStyle(PinkBorderedProgressViewStyle()) +/// } +/// } +/// +/// struct PinkBorderedProgressViewStyle: ProgressViewStyle { +/// func makeBody(configuration: Configuration) -> some View { +/// ProgressView(configuration) +/// .padding(4) +/// .border(.pink, width: 3) +/// .cornerRadius(4) +/// } +/// } +/// +/// ![Two horizontal progress views, one at 25 percent complete and the other at 75 percent, +/// each rendered with a rounded pink border.](ProgressView-4-macOS) +/// +/// OpenSwiftUI provides two built-in progress view styles, +/// ``ProgressViewStyle/linear`` and ``ProgressViewStyle/circular``, as well as +/// an automatic style that defaults to the most appropriate style in the +/// current context. The following example shows a circular progress view that +/// starts at 60 percent completed. +/// +/// struct CircularProgressDemoView: View { +/// @State private var progress = 0.6 +/// +/// var body: some View { +/// VStack { +/// ProgressView(value: progress) +/// .progressViewStyle(.circular) +/// } +/// } +/// } +/// +/// ![A ring shape, filled to 60 percent completion with a blue +/// tint.](ProgressView-5-macOS) +/// +/// On platforms other than macOS, the circular style may appear as an +/// indeterminate indicator instead. +@available(OpenSwiftUI_v2_0, *) +public struct ProgressView: View where Label: View, CurrentValueLabel: View { + var base: Base + + public var body: some View { + switch base { + case let .custom(custom): custom + case let .observing(observing): observing + } + } + + enum Base { + case custom(CustomProgressView) + case observing(FoundationProgressView) + } +} + +@available(*, unavailable) +extension ProgressView: Sendable {} + +// MARK: - Indeterminate Initializers + +@available(OpenSwiftUI_v2_0, *) +extension ProgressView where CurrentValueLabel == EmptyView { + /// Creates a progress view for showing indeterminate progress, without a + /// label. + nonisolated public init() where Label == EmptyView { + self.init(label: nil) + } + + /// Creates a progress view for showing indeterminate progress that displays + /// a custom label. + /// + /// - Parameters: + /// - label: A view builder that creates a view that describes the task + /// in progress. + nonisolated public init(@ViewBuilder label: () -> Label) { + self.init(label: label()) + } + + /// Creates a progress view for showing indeterminate progress that + /// generates its label from a localized string. + /// + /// This initializer creates a ``Text`` view on your behalf, and treats the + /// localized key similar to ``Text/init(_:tableName:bundle:comment:)``. See + /// ``Text`` for more information about localizing strings. To initialize an + /// indeterminate progress view with a string variable, use the + /// corresponding initializer that takes a `StringProtocol` instance. + /// + /// - Parameters: + /// - titleKey: The key for the progress view's localized title that + /// describes the task in progress. + nonisolated public init(_ titleKey: LocalizedStringKey) where Label == Text { + self.init(label: Text(titleKey)) + } + + /// Creates a progress view for showing indeterminate progress that + /// generates its label from a string. + /// + /// - Parameters: + /// - title: A string that describes the task in progress. + /// + /// This initializer creates a ``Text`` view on your behalf, and treats the + /// title similar to ``Text/init(verbatim:)``. See ``Text`` for more + /// information about localizing strings. To initialize a progress view with + /// a localized string key, use the corresponding initializer that takes a + /// `LocalizedStringKey` instance. + @_disfavoredOverload + nonisolated public init(_ title: S) where Label == Text, S: StringProtocol { + self.init(label: Text(title)) + } + + nonisolated init(label: Label?) { + base = .custom( + CustomProgressView( + fractionCompleted: nil, + alwaysIndeterminate: true, + label: label, + currentValueLabel: nil + ) + ) + } +} + +// MARK: - Value-Based Initializers + +@available(OpenSwiftUI_v2_0, *) +extension ProgressView { + /// Creates a progress view for showing determinate progress. + /// + /// If the value is non-`nil`, but outside the range of `0.0` through + /// `total`, the progress view pins the value to those limits, rounding to + /// the nearest possible bound. A value of `nil` represents indeterminate + /// progress, in which case the progress view ignores `total`. + /// + /// - Parameters: + /// - value: The completed amount of the task to this point, in a range + /// of `0.0` to `total`, or `nil` if the progress is indeterminate. + /// - total: The full amount representing the complete scope of the + /// task, meaning the task is complete if `value` equals `total`. The + /// default value is `1.0`. + nonisolated public init( + value: V?, + total: V = 1.0 + ) where Label == EmptyView, CurrentValueLabel == EmptyView, V: BinaryFloatingPoint { + self.init( + value: value, + total: total, + label: nil, + currentValueLabel: nil + ) + } + + /// Creates a progress view for showing determinate progress, with a + /// custom label. + /// + /// If the value is non-`nil`, but outside the range of `0.0` through + /// `total`, the progress view pins the value to those limits, rounding to + /// the nearest possible bound. A value of `nil` represents indeterminate + /// progress, in which case the progress view ignores `total`. + /// + /// - Parameters: + /// - value: The completed amount of the task to this point, in a range + /// of `0.0` to `total`, or `nil` if the progress is indeterminate. + /// - total: The full amount representing the complete scope of the + /// task, meaning the task is complete if `value` equals `total`. The + /// default value is `1.0`. + /// - label: A view builder that creates a view that describes the task + /// in progress. + nonisolated public init( + value: V?, + total: V = 1.0, + @ViewBuilder label: () -> Label + ) where CurrentValueLabel == EmptyView, V: BinaryFloatingPoint { + self.init( + value: value, + total: total, + label: label(), + currentValueLabel: nil + ) + } + + /// Creates a progress view for showing determinate progress, with a + /// custom label. + /// + /// If the value is non-`nil`, but outside the range of `0.0` through + /// `total`, the progress view pins the value to those limits, rounding to + /// the nearest possible bound. A value of `nil` represents indeterminate + /// progress, in which case the progress view ignores `total`. + /// + /// - Parameters: + /// - value: The completed amount of the task to this point, in a range + /// of `0.0` to `total`, or `nil` if the progress is indeterminate. + /// - total: The full amount representing the complete scope of the + /// task, meaning the task is complete if `value` equals `total`. The + /// default value is `1.0`. + /// - label: A view builder that creates a view that describes the task + /// in progress. + /// - currentValueLabel: A view builder that creates a view that + /// describes the level of completed progress of the task. + nonisolated public init( + value: V?, + total: V = 1.0, + @ViewBuilder label: () -> Label, + @ViewBuilder currentValueLabel: () -> CurrentValueLabel + ) where V: BinaryFloatingPoint { + self.init( + value: value, + total: total, + label: label(), + currentValueLabel: currentValueLabel() + ) + } + + /// Creates a progress view for showing determinate progress that generates + /// its label from a localized string. + /// + /// If the value is non-`nil`, but outside the range of `0.0` through + /// `total`, the progress view pins the value to those limits, rounding to + /// the nearest possible bound. A value of `nil` represents indeterminate + /// progress, in which case the progress view ignores `total`. + /// + /// This initializer creates a ``Text`` view on your behalf, and treats the + /// localized key similar to ``Text/init(_:tableName:bundle:comment:)``. See + /// ``Text`` for more information about localizing strings. To initialize a + /// determinate progress view with a string variable, use the corresponding + /// initializer that takes a `StringProtocol` instance. + /// + /// - Parameters: + /// - titleKey: The key for the progress view's localized title that + /// describes the task in progress. + /// - value: The completed amount of the task to this point, in a range + /// of `0.0` to `total`, or `nil` if the progress is + /// indeterminate. + /// - total: The full amount representing the complete scope of the + /// task, meaning the task is complete if `value` equals `total`. The + /// default value is `1.0`. + nonisolated public init( + _ titleKey: LocalizedStringKey, + value: V?, + total: V = 1.0 + ) where Label == Text, CurrentValueLabel == EmptyView, V: BinaryFloatingPoint { + self.init( + value: value, + total: total, + label: Text(titleKey), + currentValueLabel: nil + ) + } + + /// Creates a progress view for showing determinate progress that generates + /// its label from a string. + /// + /// If the value is non-`nil`, but outside the range of `0.0` through + /// `total`, the progress view pins the value to those limits, rounding to + /// the nearest possible bound. A value of `nil` represents indeterminate + /// progress, in which case the progress view ignores `total`. + /// + /// This initializer creates a ``Text`` view on your behalf, and treats the + /// title similar to ``Text/init(verbatim:)``. See ``Text`` for more + /// information about localizing strings. To initialize a determinate + /// progress view with a localized string key, use the corresponding + /// initializer that takes a `LocalizedStringKey` instance. + /// + /// - Parameters: + /// - title: The string that describes the task in progress. + /// - value: The completed amount of the task to this point, in a range + /// of `0.0` to `total`, or `nil` if the progress is + /// indeterminate. + /// - total: The full amount representing the complete scope of the + /// task, meaning the task is complete if `value` equals `total`. The + /// default value is `1.0`. + @_disfavoredOverload + nonisolated public init( + _ title: S, + value: V?, + total: V = 1.0 + ) where Label == Text, CurrentValueLabel == EmptyView, S: StringProtocol, V: BinaryFloatingPoint { + self.init( + value: value, + total: total, + label: Text(title), + currentValueLabel: nil + ) + } + + nonisolated init( + value: V?, + total: V, + label: Label?, + currentValueLabel: CurrentValueLabel? + ) where V: BinaryFloatingPoint { + var fractionCompleted: Double? { + guard let value else { + return nil + } + if value < 0 || value > total { + Log.runtimeIssues( + "ProgressView initialized with an out-of-bounds progress value. The value will be clamped to the range of `0...total`." + ) + } + guard value >= 0, + total >= 0, + value != 0 || total != 0 else { + return nil + } + return Double(value / total).clamp(min: 0, max: 1) + } + base = .custom( + CustomProgressView( + fractionCompleted: fractionCompleted, + alwaysIndeterminate: false, + label: label, + currentValueLabel: currentValueLabel + ) + ) + } +} + +// MARK: - ProgressView + Foundation Progress + +@available(OpenSwiftUI_v2_0, *) +extension ProgressView { + /// Creates a progress view for visualizing the given progress instance. + /// + /// The progress view synthesizes a default label using the + /// `localizedDescription` of the given progress instance. + nonisolated public init( + _ progress: Foundation.Progress + ) where Label == EmptyView, CurrentValueLabel == EmptyView { + base = .observing(FoundationProgressView(progress: progress)) + } +} + +// MARK: - ProgressView + Style Configuration + +@available(OpenSwiftUI_v2_0, *) +extension ProgressView { + /// Creates a progress view based on a style configuration. + /// + /// You can use this initializer within the + /// ``ProgressViewStyle/makeBody(configuration:)`` method of a + /// ``ProgressViewStyle`` to create an instance of the styled progress view. + /// This is useful for custom progress view styles that only modify the + /// current progress view style, as opposed to implementing a brand new + /// style. Because this modifier style can't know how the current style + /// represents progress, avoid making assumptions about the view's contents, + /// such as whether it uses bars or other shapes. + /// + /// The following example shows a style that adds a rounded pink border to a + /// progress view, but otherwise preserves the progress view's current + /// style: + /// + /// struct PinkBorderedProgressViewStyle: ProgressViewStyle { + /// func makeBody(configuration: Configuration) -> some View { + /// ProgressView(configuration) + /// .padding(4) + /// .border(.pink, width: 3) + /// .cornerRadius(4) + /// } + /// } + /// + /// ![Two horizontal progress views, one at 25 percent complete and the + /// other at 75 percent, each rendered with a rounded pink + /// border.](ProgressView-4-macOS) + /// + /// - Note: Progress views in widgets don't apply custom styles. + nonisolated public init( + _ configuration: ProgressViewStyleConfiguration + ) where Label == ProgressViewStyleConfiguration.Label, CurrentValueLabel == ProgressViewStyleConfiguration.CurrentValueLabel { + base = .custom( + CustomProgressView( + value: configuration.value, + label: configuration.label, + currentValueLabel: configuration.currentValueLabel + ) + ) + } +} + +// MARK: - ProgressViewValue + +enum ProgressViewValue: Codable { + case absolute(fractionCompleted: Double?, alwaysIndeterminate: Bool) + case dateRelative(interval: ClosedRange, countdown: Bool) + + private enum CodingKeys: CodingKey { + case absolute + case dateRelative + } + + private enum AbsoluteCodingKeys: CodingKey { + case fractionCompleted + case alwaysIndeterminate + } + + private enum DateRelativeCodingKeys: CodingKey { + case interval + case countdown + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .absolute(fractionCompleted, alwaysIndeterminate): + var nestedContainer = container.nestedContainer( + keyedBy: AbsoluteCodingKeys.self, + forKey: .absolute + ) + try nestedContainer.encodeIfPresent( + fractionCompleted, + forKey: .fractionCompleted + ) + try nestedContainer.encode( + alwaysIndeterminate, + forKey: .alwaysIndeterminate + ) + case let .dateRelative(interval, countdown): + var nestedContainer = container.nestedContainer( + keyedBy: DateRelativeCodingKeys.self, + forKey: .dateRelative + ) + try nestedContainer.encode(interval, forKey: .interval) + try nestedContainer.encode(countdown, forKey: .countdown) + } + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let keys = container.allKeys + guard keys.count == 1 else { + throw DecodingError.typeMismatch( + Self.self, + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Invalid number of keys found, expected one." + ) + ) + } + switch keys[0] { + case .absolute: + let nestedContainer = try container.nestedContainer( + keyedBy: AbsoluteCodingKeys.self, + forKey: .absolute + ) + self = try .absolute( + fractionCompleted: nestedContainer.decodeIfPresent( + Double.self, + forKey: .fractionCompleted + ), + alwaysIndeterminate: nestedContainer.decode( + Bool.self, + forKey: .alwaysIndeterminate + ) + ) + case .dateRelative: + let nestedContainer = try container.nestedContainer( + keyedBy: DateRelativeCodingKeys.self, + forKey: .dateRelative + ) + self = try .dateRelative( + interval: nestedContainer.decode( + ClosedRange.self, + forKey: .interval + ), + countdown: nestedContainer.decode( + Bool.self, + forKey: .countdown + ) + ) + } + } +} + +// MARK: - CustomProgressView + +@MainActor +@preconcurrency +struct CustomProgressView: PrimitiveView, UnaryView, View where Label: View, CurrentValueLabel: View { + var value: ProgressViewValue + var label: Label? + var currentValueLabel: CurrentValueLabel? + + init( + value: ProgressViewValue, + label: Label?, + currentValueLabel: CurrentValueLabel? + ) { + self.value = value + self.label = label + self.currentValueLabel = currentValueLabel + } + + init( + interval: ClosedRange, + countdown: Bool, + label: Label?, + currentValueLabel: CurrentValueLabel? + ) { + self.value = .dateRelative(interval: interval, countdown: countdown) + self.label = label + self.currentValueLabel = currentValueLabel + } + + init( + fractionCompleted: Double?, + alwaysIndeterminate: Bool, + label: Label?, + currentValueLabel: CurrentValueLabel? + ) { + self.value = .absolute( + fractionCompleted: fractionCompleted, + alwaysIndeterminate: alwaysIndeterminate + ) + self.label = label + self.currentValueLabel = currentValueLabel + } + + nonisolated static func _makeView( + view: _GraphValue, + inputs: _ViewInputs + ) -> _ViewOutputs { + let baseValue = view.value[offset: { .of(&$0.value) }] + let label = view.value[offset: { .of(&$0.label) }] + let currentValueLabel = view.value[offset: { .of(&$0.currentValueLabel) }] + let child = Child( + baseValue: baseValue, + label: label, + currentValueLabel: currentValueLabel + ) + var outputs = Child.Value._makeView( + view: _GraphValue(child), + inputs: inputs + ) + if inputs.preferences.contains(WidgetAuxiliaryViewMetadata.Key.self) { + outputs.preferences.platformItemList = nil + outputs.preferences.makePreferenceWriter( + inputs: inputs.preferences, + key: WidgetAuxiliaryViewMetadata.Key.self, + value: Attribute( + WidgetMetadataWriter( + baseValue: baseValue, + labelPref: Attribute( + LazyWidgetAuxiliaryMetadataTextImage( + flags: _AttributeType.Flags.self, + content: label, + inputs: inputs + ) + ), + currentValueLabelPref: Attribute( + LazyWidgetAuxiliaryMetadataTextImage( + flags: _AttributeType.Flags.self, + content: currentValueLabel, + inputs: inputs + ) + ), + environment: inputs.environment + ) + ) + ) + } + return outputs + } + + private struct WidgetMetadataWriter: Rule { + @Attribute var baseValue: ProgressViewValue + @Attribute var labelPref: WidgetAuxiliaryTextImagePreference? + @Attribute var currentValueLabelPref: WidgetAuxiliaryTextImagePreference? + @Attribute var environment: EnvironmentValues + + var value: WidgetAuxiliaryViewMetadata? { + let kind: WidgetAuxiliaryViewMetadata.Progress.Kind = switch baseValue { + case let .absolute(fractionCompleted, alwaysIndeterminate): .absolute(fractionCompleted, alwaysIndeterminate) + case let .dateRelative(interval, countdown): .date(interval, countdown) + } + let label = WidgetAuxiliaryViewMetadata( + item: labelPref?.list?.mergedContentItem, + url: nil, + accessibility: nil, + child: nil + ) + let currentValueLabel = WidgetAuxiliaryViewMetadata( + item: currentValueLabelPref?.list?.mergedContentItem, + url: nil, + accessibility: nil, + child: nil + ) + return WidgetAuxiliaryViewMetadata( + progress: .init( + kind: kind, + label: label, + currentValueLabel: currentValueLabel, + tint: WidgetAuxiliaryViewMetadata.tint(from: environment) + ) + ) + } + } + + private struct Child: Rule { + @Attribute var baseValue: ProgressViewValue + @Attribute var label: Label? + @Attribute var currentValueLabel: CurrentValueLabel? + + var value: some View { + ResolvedProgressView(value: baseValue) + .optionalViewAlias(ProgressViewStyleConfiguration.CurrentValueLabel.self) { + currentValueLabel + } + .optionalViewAlias(ProgressViewStyleConfiguration.Label.self) { + label + } + } + } +} + +// MARK: - ResolvedProgressView + +struct ResolvedProgressView: View { + var value: ProgressViewValue + + @OptionalViewAlias + var label: ProgressViewStyleConfiguration.Label? + + @OptionalViewAlias + var currentValueLabel: ProgressViewStyleConfiguration.CurrentValueLabel? + + var body: ResolvedProgressViewStyle { + ResolvedProgressViewStyle( + configuration: ProgressViewStyleConfiguration( + value: value, + label: label, + currentValueLabel: currentValueLabel + ) + ) + } +} diff --git a/Sources/OpenSwiftUI/View/Control/ProgressView/ProgressViewStyle.swift b/Sources/OpenSwiftUI/View/Control/ProgressView/ProgressViewStyle.swift new file mode 100644 index 000000000..e6678ee42 --- /dev/null +++ b/Sources/OpenSwiftUI/View/Control/ProgressView/ProgressViewStyle.swift @@ -0,0 +1,168 @@ +// +// ProgressViewStyle.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +@_spi(ForOpenSwiftUIOnly) +public import OpenSwiftUICore + +// MARK: - ProgressViewStyle + +/// A type that applies standard interaction behavior to all progress views +/// within a view hierarchy. +/// +/// To configure the current progress view style for a view hierarchy, use the +/// ``View/progressViewStyle(_:)`` modifier. +@available(OpenSwiftUI_v2_0, *) +@preconcurrency +@MainActor +public protocol ProgressViewStyle { + + /// A view representing the body of a progress view. + associatedtype Body: View + + /// Creates a view representing the body of a progress view. + /// + /// - Parameter configuration: The properties of the progress view being + /// created. + /// + /// The view hierarchy calls this method for each progress view where this + /// style is the current progress view style. + /// + /// - Parameter configuration: The properties of the progress view, such as + /// its preferred progress type. + @ViewBuilder + func makeBody(configuration: Configuration) -> Body + + /// A type alias for the properties of a progress view instance. + typealias Configuration = ProgressViewStyleConfiguration +} + +// MARK: - ProgressViewStyleConfiguration + +/// The properties of a progress view instance. +@available(OpenSwiftUI_v2_0, *) +public struct ProgressViewStyleConfiguration { + + /// A type-erased label describing the task represented by the progress + /// view. + public struct Label: ViewAlias { + init() { + _openSwiftUIEmptyStub() + } + } + + /// A type-erased label that describes the current value of a progress view. + public struct CurrentValueLabel: ViewAlias { + init() { + _openSwiftUIEmptyStub() + } + } + + let value: ProgressViewValue + + /// The completed fraction of the task represented by the progress view, + /// from `0.0` (not yet started) to `1.0` (fully complete), or `nil` if the + /// progress is indeterminate or relative to a date interval. + public let fractionCompleted: Double? + + var alwaysIndeterminate: Bool + + /// A view that describes the task represented by the progress view. + /// + /// If `nil`, then the task is self-evident from the surrounding context, + /// and the style does not need to provide any additional description. + /// + /// If the progress view is defined using a `Progress` instance, then this + /// label is equivalent to its `localizedDescription`. + public var label: Label? + + /// A view that describes the current value of a progress view. + /// + /// If `nil`, then the value of the progress view is either self-evident + /// from the surrounding context or unknown, and the style does not need to + /// provide any additional description. + /// + /// If the progress view is defined using a `Progress` instance, then this + /// label is equivalent to its `localizedAdditionalDescription`. + public var currentValueLabel: CurrentValueLabel? + + init( + value: ProgressViewValue, + label: Label?, + currentValueLabel: CurrentValueLabel? + ) { + self.value = value + switch value { + case let .absolute(fractionCompleted, alwaysIndeterminate): + self.fractionCompleted = fractionCompleted + self.alwaysIndeterminate = alwaysIndeterminate + case .dateRelative: + self.fractionCompleted = nil + self.alwaysIndeterminate = false + } + self.label = label + self.currentValueLabel = currentValueLabel + } +} + +@available(*, unavailable) +extension ProgressViewStyleConfiguration: Sendable {} + +@available(*, unavailable) +extension ProgressViewStyleConfiguration.CurrentValueLabel: Sendable {} + +@available(*, unavailable) +extension ProgressViewStyleConfiguration.Label: Sendable {} + +// MARK: - ProgressViewStyleModifier + +struct ProgressViewStyleModifier