210 lines
6.0 KiB
Swift
210 lines
6.0 KiB
Swift
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
|
|
|
|
/// The width and height of the button's label, scaled relative to the current dynamic type size.
|
|
@ScaledMetric private var size = Constant.Size.button
|
|
|
|
/// Whether the color scheme of the button's label should be inverted from the environment's.
|
|
private let invertStyle: Bool
|
|
|
|
// 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)
|
|
}
|
|
}
|