Skip to content

[CM-1205] Create Basic Stepper #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@ let package = Package(
)
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
// For UI layout support, contrast ratio calculations.
.package(url: "https://github.com/yml-org/YCoreUI.git", from: "1.4.0"),
// For Typography support
.package(url: "https://github.com/yml-org/YMatterType.git", from: "1.4.0")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "YStepper",
dependencies: []
dependencies: ["YCoreUI", "YMatterType"]
),
.testTarget(
name: "YStepperTests",
Expand Down
23 changes: 23 additions & 0 deletions Sources/YStepper/Enums/YStepper+Images.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// YStepper+Images.swift
// YStepper
//
// Created by Sahil Saini on 07/03/23.
// Copyright © 2023 Y Media Labs. All rights reserved.
//

import UIKit
import YCoreUI

/// Collection of Images
enum Images: String, CaseIterable {
case increment = "plus.circle"
case decrement = "minus.circle"
case delete = "trash.circle"
}

extension Images: ImageAsset {
func loadImage() -> UIImage? {
UIImage(systemName: rawValue)
}
}
16 changes: 16 additions & 0 deletions Sources/YStepper/Protocols/StepperDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// StepperDelegate.swift
// YStepper
//
// Created by Sahil Saini on 07/03/23.
// Copyright © 2023 Y Media Labs. All rights reserved.
//

import Foundation

/// Observes `Stepper` actions
public protocol StepperDelegate: AnyObject {
/// This method is used to inform when there is a change in value.
/// - Parameter newValue: new value
func valueDidChange(newValue: Double)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// Stepper+AppearanceObserver.swift
// YStepper
//
// Created by Sahil Saini on 06/03/23.
// Copyright © 2023 Y Media Labs. All rights reserved.
//

import Foundation

// extension for appearance
extension Stepper {
class AppearanceObserver: ObservableObject {
@Published var appearance: StepperControl.Appearance

/// initializer for theme observer
/// - Parameter appearance: appearance object
init(appearance: StepperControl.Appearance = .default) {
self.appearance = appearance
}
}
}
34 changes: 34 additions & 0 deletions Sources/YStepper/SwiftUI/Observers/Stepper+ValueObserver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// Stepper+ValueObserver.swift
// YStepper
//
// Created by Sahil Saini on 06/03/23.
// Copyright © 2023 Y Media Labs. All rights reserved.
//

import Foundation

// extension for appearance
extension Stepper {
class ValueObserver: ObservableObject {
@Published var minimumValue: Double
@Published var maximumValue: Double
@Published var stepValue: Double
@Published var value: Double
@Published var decimalValue: Int

init(
minimumValue: Double = 0,
maximumValue: Double = 100,
stepValue: Double = 1,
value: Double = 0,
decimalValue: Int = 0
) {
self.minimumValue = minimumValue
self.maximumValue = maximumValue
self.stepValue = stepValue
self.value = value
self.decimalValue = decimalValue
}
}
}
200 changes: 200 additions & 0 deletions Sources/YStepper/SwiftUI/Views/Stepper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
//
// Stepper.swift
// YStepper
//
// Created by Sahil Saini on 06/03/23.
// Copyright © 2023 Y Media Labs. All rights reserved.
//

import SwiftUI
import YMatterType

/// A SwiftUI stepper control.
public struct Stepper {
enum ButtonType {
case increment
case decrement
}

let minimumSize: CGSize = CGSize(width: 44, height: 44)

@ObservedObject private var appearanceObserver = Stepper.AppearanceObserver()
@ObservedObject private var valueObserver = Stepper.ValueObserver()

/// Receive value change notification
public weak var delegate: StepperDelegate?
/// Stepper appearance
public var appearance: StepperControl.Appearance {
get {
self.appearanceObserver.appearance
}
set {
self.appearanceObserver.appearance = newValue
}
}
/// Optional minimum vale. Minimum possible value for the stepper.
public var minimumValue: Double {
get { valueObserver.minimumValue }
set {
onMinimumValueChange(newValue: newValue)
}
}
/// Optional maximum value. Maximum possible value for the stepper.
public var maximumValue: Double {
get { valueObserver.maximumValue }
set {
onMaximumValueChange(newValue: newValue)
}
}
/// Optional step value. The step, or increment, value for the stepper.
public var stepValue: Double {
get { valueObserver.stepValue }
set { valueObserver.stepValue = newValue }
}

/// Stepper's current value
public var value: Double {
get { valueObserver.value }
set { onValueChange(newValue: newValue) }
}
/// Decimal digits in current value
public var decimalPlaces: Int {
get { valueObserver.decimalValue }
set { valueObserver.decimalValue = newValue }
}
/// Initializes Stepper
/// - Parameters:
/// - appearance: appearance for the stepper. Default is `.default`
/// - minimumValue: minimum value. Default is `0`
/// - maximumValue: maximum value. Default is `100`
/// - stepValue: Step value. Default is `1`
/// - value: Current value. Default is `0` or minimumValue (if provided)
public init(
appearance: StepperControl.Appearance = .default,
minimumValue: Double = 0,
maximumValue: Double = 100,
stepValue: Double = 1,
value: Double = 0
) {
self.appearance = appearance
self.minimumValue = minimumValue
self.maximumValue = maximumValue
self.stepValue = stepValue
self.value = (minimumValue...maximumValue).contains(value) ?
minimumValue : value
}
}

extension Stepper: View {
/// :nodoc:
public var body: some View {
HStack(spacing: 0) {
generateButton(buttonType: .decrement) {
valueObserver.value -= stepValue
updateCurrentValue(newValue: valueObserver.value)
}

TextStyleLabel(getValueText(), typography: appearance.textStyle.typography) { label in
label.textAlignment = .center
}.frame(minWidth: minimumSize.width, idealWidth: minimumSize.height)

generateButton(buttonType: .increment) {
valueObserver.value += stepValue
updateCurrentValue(newValue: valueObserver.value)
}
}
.background(
Capsule()
.strokeBorder(Color(appearance.borderColor), lineWidth: appearance.borderWidth)
.background(Capsule().foregroundColor(Color(appearance.backgroundColor)))
)
}

func generateButton(
buttonType: ButtonType,
action: @escaping () -> Void
) -> some View {
let button = Button(action: action) {
switch buttonType {
case .increment:
getIncrementImage()
case .decrement:
getImageForDecrementButton()
}
}
return button.frame(minWidth: minimumSize.width, minHeight: minimumSize.height)
}
}

extension Stepper {
func getValueText() -> String {
String(format: "%.\(decimalPlaces)f", value)
}

func updateCurrentValue(newValue: Double) {
if newValue < valueObserver.minimumValue {
valueObserver.value = valueObserver.minimumValue
}

if newValue > valueObserver.maximumValue {
valueObserver.value = valueObserver.maximumValue
}
delegate?.valueDidChange(newValue: valueObserver.value)
}
}

extension Stepper {
func getDeleteImage() -> Image {
Image(uiImage: appearance.deleteImage ?? StepperControl.Appearance.defaultDeleteImage)
}

func getIncrementImage() -> Image {
Image(uiImage: appearance.incrementImage ?? StepperControl.Appearance.defaultIncrementImage)
}

func getDecrementImage() -> Image {
Image(uiImage: appearance.decrementImage ?? StepperControl.Appearance.defaultDecrementImage)
}

func getImageForDecrementButton() -> Image {
if appearance.hasDeleteButton
&& value <= stepValue
&& minimumValue == 0 {
return getDeleteImage()
}
return getDecrementImage()
}
}

private extension Stepper {
func onMinimumValueChange(newValue: Double) {
if minimumValue < maximumValue {
valueObserver.minimumValue = newValue
if value < minimumValue {
valueObserver.value = minimumValue
}
}
}

func onMaximumValueChange(newValue: Double) {
if minimumValue < maximumValue {
valueObserver.maximumValue = newValue
}
}

func onValueChange(newValue: Double) {
if (minimumValue...maximumValue).contains(newValue) {
valueObserver.value = newValue
}
}
}

struct Stepper_Previews: PreviewProvider {
static var previews: some View {
HStack {
Spacer().frame(maxWidth: .infinity)
Stepper()
Spacer().frame(maxWidth: .infinity)
}
}
}
73 changes: 73 additions & 0 deletions Sources/YStepper/UIKit/StepperControl+Appearance.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//
// StepperControl+Appearance.swift
// YStepper
//
// Created by Sahil Saini on 06/03/23.
// Copyright © 2023 Y Media Labs. All rights reserved.
//

import UIKit
import YMatterType

extension StepperControl {
/// Appearance for stepper that contains typography and color properties
public struct Appearance {
/// Typography of stepper value label
public var textStyle: (textColor: UIColor, typography: Typography)
/// Background color for stepper view
public var backgroundColor: UIColor
/// Border color for stepper view
public var borderColor: UIColor
/// Border width for stepper view
public var borderWidth: CGFloat
/// Delete button image
public var deleteImage: UIImage?
/// Increment button image
public var incrementImage: UIImage?
/// Decrement button image
public var decrementImage: UIImage?
/// Whether to show delete button or not.
var hasDeleteButton: Bool { deleteImage != nil }

/// Initializer for appearance
/// - Parameters:
/// - textStyle: Typography and text color for valueText label.
/// Default is `(UIColor.label, Typography.systemLabel)`
/// - foregroundColor: Foreground color for valueText. Default is `.label`
/// - backgroundColor: Background color for stepper view. Default is `.systemBackground`
/// - borderColor: Border color for stepper view. Default is `UIColor.label`
/// - borderWidth: Border width for day view. Default is `1.0`
/// - deleteImage: Delete button image. Default is `Appearance.defaultDeleteImage`
/// - incrementImage: Increment button image. Default is `Appearance.defaultIncrementImage`
/// - decrementImage: Decrement button image. Default is `Appearance.defaultDecrementImage`
public init(
textStyle: (textColor: UIColor, typography: Typography) = (.label, .systemLabel),
foregroundColor: UIColor = .label,
backgroundColor: UIColor = .systemBackground,
borderColor: UIColor = .label,
borderWidth: CGFloat = 1.0,
deleteImage: UIImage? = Appearance.defaultDeleteImage,
incrementImage: UIImage? = Appearance.defaultIncrementImage,
decrementImage: UIImage? = Appearance.defaultDecrementImage
) {
self.textStyle = textStyle
self.backgroundColor = backgroundColor
self.borderColor = borderColor
self.borderWidth = borderWidth
self.deleteImage = deleteImage
self.incrementImage = incrementImage
self.decrementImage = decrementImage
}
}
}

extension StepperControl.Appearance {
/// Default stepper appearance
public static let `default` = StepperControl.Appearance()
/// Default image for delete button. Is a `trash.circle` from SF Symbols in template rendering mode
public static let defaultDeleteImage = Images.delete.image
/// Default image for increment button. Is a `plus.circle` from SF Symbols in template rendering mode
public static let defaultIncrementImage = Images.increment.image
/// Default image for decrement button. Is a `minus.circle` from SF Symbols in template rendering mode
public static let defaultDecrementImage = Images.decrement.image
}
Loading