Skip to content

Commit

Permalink
PM-17414: Update design of stepper component (#1319)
Browse files Browse the repository at this point in the history
  • Loading branch information
matt-livefront authored Feb 4, 2025
1 parent a29f941 commit b8534eb
Show file tree
Hide file tree
Showing 33 changed files with 344 additions and 103 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ extension StyleGuideFont {
/// The font for the monospaced body style.
static let bodyMonospaced = StyleGuideFont(font: .system(.body, design: .monospaced), lineHeight: 22, size: 17)

/// The font for the bold semibody style.
static let bodySemibold = body.with(font: FontFamily.DMSans.semiBold)

/// The font for the callout style.
static let callout = StyleGuideFont.dmSans(lineHeight: 18, size: 13, textStyle: .callout)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ final class ButtonStylesTests: BitwardenTestCase {
}
.disabled(true)
}
.buttonStyle(CircleButtonStyle())
.buttonStyle(CircleButtonStyle(diameter: 50))
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ struct CircleButtonStyle: ButtonStyle {
: Asset.Colors.buttonFilledDisabledBackground.swiftUIColor
}

/// The diameter of the circle in the button.
let diameter: CGFloat

/// The color of the foreground elements, including text and template images.
var foregroundColor: Color {
isEnabled
Expand All @@ -27,7 +30,7 @@ struct CircleButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundColor(foregroundColor)
.frame(width: 50, height: 50)
.frame(width: diameter, height: diameter)
.background(backgroundColor)
.clipShape(Circle())
.opacity(configuration.isPressed ? 0.5 : 1)
Expand All @@ -50,7 +53,7 @@ struct CircleButtonStyle: ButtonStyle {
)
)
}
.buttonStyle(CircleButtonStyle())
.buttonStyle(CircleButtonStyle(diameter: 50))
}
.padding()
}
Expand Down
9 changes: 8 additions & 1 deletion BitwardenShared/UI/Platform/Application/Extensions/Int.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import Foundation

// MARK: - TimeInterval
// MARK: - Int

extension Int {
// MARK: Properties

/// Returns the number of digits within the value.
var numberOfDigits: Int {
abs(self).description.count
}

// MARK: Methods

/// Creates a string in the format of `HH:mm` from a number of seconds.
Expand Down
11 changes: 11 additions & 0 deletions BitwardenShared/UI/Platform/Application/Extensions/IntTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ import XCTest
class IntTests: BitwardenTestCase {
// MARK: Tests

/// `numberOfDigits()` returns the number of digits within the value.
func test_numberOfDigits() {
XCTAssertEqual((-12345).numberOfDigits, 5)
XCTAssertEqual((-1).numberOfDigits, 1)
XCTAssertEqual(0.numberOfDigits, 1)
XCTAssertEqual(1.numberOfDigits, 1)
XCTAssertEqual(10.numberOfDigits, 2)
XCTAssertEqual(999.numberOfDigits, 3)
XCTAssertEqual(12345.numberOfDigits, 5)
}

/// `.timeInHoursMinutes()` formats the time interval correctly.
func test_hours() {
let string = 3600.timeInHoursMinutes()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ private struct ViewSizeKey: PreferenceKey {
static var defaultValue = CGSize.zero

static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
guard nextValue() != defaultValue else { return }
value = nextValue()
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "minus16.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
Binary file not shown.
243 changes: 243 additions & 0 deletions BitwardenShared/UI/Platform/Application/Views/BitwardenStepper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import SwiftUI

// MARK: - BitwardenStepper

/// A custom stepper component which performs increment and decrement actions.
///
struct BitwardenStepper<Label: View, Footer: View>: View {
// MARK: Properties

/// Whether a text field can be used to type in the value as an alternative to using the
/// stepper buttons.
let allowTextFieldInput: Bool

/// An optional footer to display below the stepper.
let footer: Footer?

/// The label to display for the stepper.
let label: Label

/// The range that describes the upper and lower bounds allowed by the stepper.
let range: ClosedRange<Int>

/// An accessibility identifier for the text field.
let textFieldAccessibilityIdentifier: String?

/// The current value of the stepper.
@Binding var value: Int

// MARK: Private Properties

@Environment(\.dynamicTypeSize) private var dynamicTypeSize

/// A state variable to track whether the text field is focused.
@FocusState private var isTextFieldFocused: Bool

/// The size of the stepper view.
@SwiftUI.State private var viewSize = CGSize.zero

// MARK: Computed Properties

/// Returns a fixed width for the value label. This prevents the buttons from changing positions
/// as the frame of the value changes due to variable widths for each digit's font.
var valueWidth: CGFloat {
// Create a string that contains a zero for each digit in the current value of the stepper
// (e.g. if the stepper's value is 10, use "00"). "0" is used since it's the widest integer
// value, so determining the size of the zero string gives the maximum possible width of
// the value label for the current number of digits.
//
// This width will change as the number of digits changes (e.g. "9" to "10"), but that's
// better than it changing for each digit (e.g. "0" to "1").
let zeroString = String(repeating: "0", count: value.numberOfDigits)
let font = FontFamily.DMSans.semiBold.font(size: StyleGuideFont.body.size)
let traitCollection = UITraitCollection(
preferredContentSizeCategory: UIContentSizeCategory(dynamicTypeSize)
)
let scaledFont = UIFontMetrics.default.scaledFont(for: font, compatibleWith: traitCollection)
let idealTextSize = (zeroString as NSString).size(withAttributes: [.font: scaledFont])

// Use a max width to prevent the value's frame from exceeding the parent's. Subtracting off
// 150 ensures there's some minimum room for the stepper buttons and label.
let maxWidth = max(viewSize.width - 150, 0)
return min(idealTextSize.width, maxWidth)
}

// MARK: View

var body: some View {
VStack(spacing: 0) {
contentView()

footerView()
}
}

// MARK: Initialization

/// Initialize a `BitwardenStepper`.
///
/// - Parameters:
/// - value: The current value of the stepper.
/// - range: The range that describes the upper and lower bounds allowed by the stepper.
/// - allowTextFieldInput: Whether a text field can be used to type in the value as an
/// alternative to using the stepper buttons.
/// - textFieldAccessibilityIdentifier: An accessibility identifier for the text field.
/// - label: The label to display for the stepper.
/// - footer: A footer to display below the stepper.
///
init(
value: Binding<Int>,
in range: ClosedRange<Int>,
allowTextFieldInput: Bool = false,
textFieldAccessibilityIdentifier: String? = nil,
@ViewBuilder label: () -> Label,
@ViewBuilder footer: () -> Footer
) {
self.allowTextFieldInput = allowTextFieldInput
self.footer = footer()
self.label = label()
self.range = range
self.textFieldAccessibilityIdentifier = textFieldAccessibilityIdentifier
_value = value
}

/// Initialize a `BitwardenStepper`.
///
/// - Parameters:
/// - value: The current value of the stepper.
/// - range: The range that describes the upper and lower bounds allowed by the stepper.
/// - allowTextFieldInput: Whether a text field can be used to type in the value as an
/// alternative to using the stepper buttons.
/// - textFieldAccessibilityIdentifier: An accessibility identifier for the text field.
/// - label: The label to display for the stepper.
///
init(
value: Binding<Int>,
in range: ClosedRange<Int>,
allowTextFieldInput: Bool = false,
textFieldAccessibilityIdentifier: String? = nil,
@ViewBuilder label: () -> Label
) where Footer == EmptyView {
self.allowTextFieldInput = allowTextFieldInput
footer = nil
self.label = label()
self.range = range
self.textFieldAccessibilityIdentifier = textFieldAccessibilityIdentifier
_value = value
}

// MARK: Private

/// The main content of the view displaying the stepper and its label.
@ViewBuilder
private func contentView() -> some View {
HStack(spacing: 12) {
label
.frame(maxWidth: .infinity, alignment: .leading)

Button {
value -= 1
} label: {
Asset.Images.minus16.swiftUIImage
}
.buttonStyle(CircleButtonStyle(diameter: 30))
.disabled(value <= range.lowerBound)
.id("decrement") // Used for ViewInspector.

Group {
if allowTextFieldInput {
textField()
} else {
Text(String(value))
.styleGuide(.body, weight: .semibold)
.foregroundColor(Asset.Colors.textSecondary.swiftUIColor)
}
}
.frame(width: valueWidth)

Button {
value += 1
} label: {
Asset.Images.plus16.swiftUIImage
}
.buttonStyle(CircleButtonStyle(diameter: 30))
.disabled(value >= range.upperBound)
.id("increment") // Used for ViewInspector.
}
.onSizeChanged { size in
viewSize = size
}
.accessibilityRepresentation {
Stepper(value: $value, in: range) {
label
}
}
.padding(16)
}

/// An optional footer which is displayed with a divider below the stepper content.
@ViewBuilder
private func footerView() -> some View {
if let footer {
Divider()
.padding(.leading, 16)

footer
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}

/// A text field which can be used to change the value of the stepper, as an alternative to the
/// increment and decrement buttons.
private func textField() -> some View {
TextField(
"",
text: Binding(
get: { String(value) },
set: { newValue in
guard let intValue = Int(newValue) else { return }
value = intValue
}
)
)
.focused($isTextFieldFocused)
.keyboardType(.numberPad)
.styleGuide(.bodySemibold)
.foregroundStyle(Asset.Colors.textSecondary.swiftUIColor)
.multilineTextAlignment(.center)
.textFieldStyle(.plain)
.accessibilityIdentifier(textFieldAccessibilityIdentifier ?? "")
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button(Localizations.save) {
isTextFieldFocused = false
}
}
}
}
}

// MARK: - Previews

#if DEBUG
@available(iOS 17, *)
#Preview {
@Previewable @SwiftUI.State var value = 1

VStack {
BitwardenStepper(value: $value, in: 1 ... 4) {
Text("Value")
}

BitwardenStepper(value: $value, in: 1 ... 4) {
Text("Value")
} footer: {
Text("Footer")
}
}
.padding()
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ struct FloatingActionButton: View {
)
)
}
.buttonStyle(CircleButtonStyle())
.buttonStyle(CircleButtonStyle(diameter: 50))
.accessibilitySortPriority(1)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,15 @@ struct StepperFieldView<State>: View {
let field: StepperField<State>

var body: some View {
Stepper(
BitwardenStepper(
value: Binding(get: { field.value }, set: action),
in: field.range
) {
HStack {
Text(field.title)
.styleGuide(.body)
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)

Spacer()

Text(String(field.value))
.styleGuide(.body, monoSpacedDigit: true)
.foregroundColor(Asset.Colors.textSecondary.swiftUIColor)
}
.padding(.trailing, 4)
Text(field.title)
.styleGuide(.body)
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
}
.accessibilityIdentifier(field.accessibilityId ?? field.id)
.padding(16)
}

// MARK: Initialization
Expand Down
Loading

0 comments on commit b8534eb

Please sign in to comment.