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 inverted 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. 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 /// 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: 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, colorSchemeInverted ) .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 opposite to the environment's, under which the label's background fill resolves to the inverted background color. var colorSchemeInverted: ColorScheme { 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``. static var recording: RecordingButtonStyle { .init() } } // 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 Button {} label: { Image(.Icon.record) .resizable() } .buttonStyle(.recording) .disabled(isDisabled) .safeAreaInset(edge: .bottom) { Toggle( "Button disabled", isOn: $isDisabled ) .fontWeight(.semibold) .dynamicTypeSize(.large) .padding() .tint(.red) } }