import SwiftUI /// The button style for the controls of a ``RecordingView``. /// /// The style renders the button's label, filled with the background color of the label's color scheme, over a padded, circular red background, shrinks it /// slightly while pressed, and dims it while disabled. Both the label and its padding scale relative to the current dynamic type size. /// /// By default, the label's color scheme is inverted from the environment's, so its fill contrasts with the surroundings; this behavior can be turned off /// through the ``init(invertStyle:)`` initializer. struct RecordingButtonStyle: ButtonStyle { // MARK: Properties /// The color scheme of the environment. @Environment(\.colorScheme) private var colorScheme /// Whether the button allows user interaction. @Environment(\.isEnabled) private var isEnabled /// The padding between the button's label and its background, scaled relative to the current dynamic type size. @ScaledMetric private var padding = Constant.Padding.button /// Whether the color scheme of the button's label should be inverted from the environment's. private let invertStyle: Bool /// The width and height of the button's label, scaled relative to the current dynamic type size. @ScaledMetric private var size = Constant.Size.button // MARK: Initializers /// Creates a recording button style. /// /// - Parameter invertStyle: Whether the foreground style of the button's label should be inverted from the environment's. init( invertStyle: Bool = false ) { self.invertStyle = invertStyle } // MARK: Methods /// Creates the styled body of the button. /// /// - Parameter configuration: The properties of the button being styled. func makeBody( configuration: Configuration ) -> some View { configuration.label .frame( width: size, height: size ) .padding(padding) .scaledToFit() .foregroundStyle(.background) .environment( \.colorScheme, colorSchemeLabel ) .background( Circle() .fill(.red) ) .opacity(opacity) .scaleEffect(scaleEffect(configuration.isPressed)) .animation( .easeOut(duration: Constant.Animation.duration), value: configuration.isPressed ) } } // MARK: - Helpers private extension RecordingButtonStyle { // MARK: Computed /// The color scheme for the button's label: the opposite of the environment's when ``invertStyle`` is set, the environment's otherwise. var colorSchemeLabel: ColorScheme { guard invertStyle else { return colorScheme } return colorScheme == .dark ? .light : .dark } /// The opacity of the button: dimmed while disabled, fully opaque otherwise. var opacity: Double { isEnabled ? Constant.Opacity.enabled : Constant.Opacity.disabled } // MARK: Methods /// Provides the scale of the button for a press state. /// /// - Parameter isPressed: Whether the button is currently pressed. /// - Returns: A slightly shrunken scale while pressed, the resting scale otherwise. func scaleEffect( _ isPressed: Bool ) -> CGFloat { isPressed ? Constant.Scale.pressed : Constant.Scale.idle } } // MARK: - Styles extension ButtonStyle where Self == RecordingButtonStyle { /// The button style for the controls of a ``RecordingView``, inverting the color scheme of the button's label. static var recording: RecordingButtonStyle { .init() } /// The button style for the controls of a ``RecordingView``. /// /// - Parameter invertStyle: Whether the color scheme of the button's label should be inverted from the environment's. /// - Returns: A recording button style with the given color scheme behavior. static func recording( invertStyle: Bool ) -> RecordingButtonStyle { .init(invertStyle: invertStyle) } } // MARK: - Constants /// The constant values used across the button style. private enum Constant { /// The animation constants. enum Animation { /// The duration of the press animation. static let duration: TimeInterval = 0.15 } /// The opacity constants. enum Opacity { /// The opacity of a disabled button. static let disabled: Double = 0.4 /// The opacity of an enabled button. static let enabled: Double = 1 } /// The padding constants. enum Padding { /// The base padding between a button's label and its background, before dynamic type scaling. static let button: CGFloat = 8 } /// The scale constants. enum Scale { /// The scale of a button at rest. static let idle: CGFloat = 1 /// The scale of a button while pressed. static let pressed: CGFloat = 0.85 } /// The size constants. enum Size { /// The base width and height of a button's label, before dynamic type scaling. static let button: CGFloat = 16 } } // MARK: - Previews #Preview( "Recording button style" ) { @Previewable @State var isDisabled: Bool = false @Previewable @State var isStyleInverted: Bool = false Button { // Button action closure. } label: { Image(.Icon.record) .resizable() } .buttonStyle(.recording( invertStyle: isStyleInverted )) .disabled(isDisabled) .safeAreaInset(edge: .bottom) { VStack { Toggle( "Style inverted", isOn: $isStyleInverted ) Toggle( "Button disabled", isOn: $isDisabled ) } .fontWeight(.semibold) .dynamicTypeSize(.large) .padding() .tint(.red) } }