Skip to content

Commit 183b632

Browse files
Merge pull request #83 from componentskit/improve-button
improve button
2 parents 03c1c5b + 04ea424 commit 183b632

File tree

6 files changed

+274
-34
lines changed

6 files changed

+274
-34
lines changed

Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift

+23-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ struct ButtonPreview: View {
66
@State private var model = ButtonVM {
77
$0.title = "Button"
88
}
9-
9+
1010
var body: some View {
1111
VStack {
1212
PreviewWrapper(title: "UIKit") {
@@ -19,12 +19,33 @@ struct ButtonPreview: View {
1919
Form {
2020
AnimationScalePicker(selection: self.$model.animationScale)
2121
ComponentOptionalColorPicker(selection: self.$model.color)
22+
Picker("Content Spacing", selection: self.$model.contentSpacing) {
23+
Text("4").tag(CGFloat(4))
24+
Text("8").tag(CGFloat(8))
25+
Text("12").tag(CGFloat(12))
26+
}
2227
ComponentRadiusPicker(selection: self.$model.cornerRadius) {
2328
Text("Custom: 20px").tag(ComponentRadius.custom(20))
2429
}
25-
ButtonFontPicker(selection: self.$model.font)
2630
Toggle("Enabled", isOn: self.$model.isEnabled)
31+
ButtonFontPicker(selection: self.$model.font)
2732
Toggle("Full Width", isOn: self.$model.isFullWidth)
33+
Picker("Image Location", selection: self.$model.imageLocation) {
34+
Text("Leading").tag(ButtonVM.ImageLocation.leading)
35+
Text("Trailing").tag(ButtonVM.ImageLocation.trailing)
36+
}
37+
Picker("Image Source", selection: self.$model.imageSrc) {
38+
Text("SF Symbol").tag(ButtonVM.ImageSource.sfSymbol("camera.fill"))
39+
Text("Local").tag(ButtonVM.ImageSource.local("avatar_placeholder"))
40+
Text("None").tag(Optional<ButtonVM.ImageSource>.none)
41+
}
42+
Toggle("Loading", isOn: self.$model.isLoading)
43+
Toggle("Show Title", isOn: Binding<Bool>(
44+
get: { !self.model.title.isEmpty },
45+
set: { newValue in
46+
self.model.title = newValue ? "Button" : ""
47+
}
48+
))
2849
SizePicker(selection: self.$model.size)
2950
Picker("Style", selection: self.$model.style) {
3051
Text("Filled").tag(ButtonStyle.filled)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Foundation
2+
3+
/// Specifies the position of the image relative to the button's title.
4+
extension ButtonVM {
5+
public enum ImageLocation {
6+
/// The image is displayed before the title.
7+
case leading
8+
/// The image is displayed after the title.
9+
case trailing
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import Foundation
2+
3+
/// Defines the image source options for a button.
4+
extension ButtonVM {
5+
public enum ImageSource: Hashable {
6+
/// An image loaded from a system SF Symbol.
7+
///
8+
/// - Parameter name: The name of the SF Symbol.
9+
case sfSymbol(String)
10+
11+
/// An image loaded from a local asset.
12+
///
13+
/// - Parameters:
14+
/// - name: The name of the local image asset.
15+
/// - bundle: The bundle containing the image resource. Defaults to `nil` to use the main bundle.
16+
case local(String, bundle: Bundle? = nil)
17+
}
18+
}

Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift

+81-11
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@ import UIKit
22

33
/// A model that defines the appearance properties for a button component.
44
public struct ButtonVM: ComponentVM {
5-
/// The text displayed on the button.
6-
public var title: String = ""
7-
85
/// The scaling factor for the button's press animation, with a value between 0 and 1.
96
///
107
/// Defaults to `.medium`.
@@ -13,6 +10,11 @@ public struct ButtonVM: ComponentVM {
1310
/// The color of the button.
1411
public var color: ComponentColor?
1512

13+
/// The spacing between the button's title and its image or loading indicator.
14+
///
15+
/// Defaults to `8.0`.
16+
public var contentSpacing: CGFloat = 8.0
17+
1618
/// The corner radius of the button.
1719
///
1820
/// Defaults to `.medium`.
@@ -23,6 +25,14 @@ public struct ButtonVM: ComponentVM {
2325
/// If not provided, the font is automatically calculated based on the button's size.
2426
public var font: UniversalFont?
2527

28+
/// The position of the image relative to the button's title.
29+
///
30+
/// Defaults to `.leading`.
31+
public var imageLocation: ImageLocation = .leading
32+
33+
/// The source of the image to be displayed.
34+
public var imageSrc: ImageSource?
35+
2636
/// A Boolean value indicating whether the button is enabled or disabled.
2737
///
2838
/// Defaults to `true`.
@@ -33,6 +43,16 @@ public struct ButtonVM: ComponentVM {
3343
/// Defaults to `false`.
3444
public var isFullWidth: Bool = false
3545

46+
/// A Boolean value indicating whether the button is currently in a loading state.
47+
///
48+
/// Defaults to `false`.
49+
public var isLoading: Bool = false
50+
51+
/// The loading VM used for the loading indicator.
52+
///
53+
/// If not provided, a default loading view model is used.
54+
public var loadingVM: LoadingVM?
55+
3656
/// The predefined size of the button.
3757
///
3858
/// Defaults to `.medium`.
@@ -43,21 +63,36 @@ public struct ButtonVM: ComponentVM {
4363
/// Defaults to `.filled`.
4464
public var style: ButtonStyle = .filled
4565

66+
/// The text displayed on the button.
67+
public var title: String = ""
68+
4669
/// Initializes a new instance of `ButtonVM` with default values.
4770
public init() {}
4871
}
4972

5073
// MARK: Shared Helpers
5174

5275
extension ButtonVM {
76+
var isInteractive: Bool {
77+
self.isEnabled && !self.isLoading
78+
}
79+
var preferredLoadingVM: LoadingVM {
80+
return self.loadingVM ?? .init {
81+
$0.color = .init(
82+
main: foregroundColor,
83+
contrast: self.color?.main ?? .background
84+
)
85+
$0.size = .small
86+
}
87+
}
5388
var backgroundColor: UniversalColor? {
5489
switch self.style {
5590
case .filled:
5691
let color = self.color?.main ?? .content2
57-
return color.enabled(self.isEnabled)
92+
return color.enabled(self.isInteractive)
5893
case .light:
5994
let color = self.color?.background ?? .content1
60-
return color.enabled(self.isEnabled)
95+
return color.enabled(self.isInteractive)
6196
case .plain, .bordered:
6297
return nil
6398
}
@@ -69,7 +104,7 @@ extension ButtonVM {
69104
case .plain, .light, .bordered:
70105
self.color?.main ?? .foreground
71106
}
72-
return color.enabled(self.isEnabled)
107+
return color.enabled(self.isInteractive)
73108
}
74109
var borderWidth: CGFloat {
75110
switch self.style {
@@ -85,7 +120,7 @@ extension ButtonVM {
85120
return nil
86121
case .bordered:
87122
if let color {
88-
return color.main.enabled(self.isEnabled)
123+
return color.main.enabled(self.isInteractive)
89124
} else {
90125
return .divider
91126
}
@@ -112,6 +147,13 @@ extension ButtonVM {
112147
case .large: 52
113148
}
114149
}
150+
var imageSide: CGFloat {
151+
switch self.size {
152+
case .small: 20
153+
case .medium: 24
154+
case .large: 28
155+
}
156+
}
115157
var horizontalPadding: CGFloat {
116158
return switch self.size {
117159
case .small: 16
@@ -121,6 +163,21 @@ extension ButtonVM {
121163
}
122164
}
123165

166+
extension ButtonVM {
167+
var image: UIImage? {
168+
guard let imageSrc else { return nil }
169+
switch imageSrc {
170+
case .sfSymbol(let name):
171+
return UIImage(systemName: name)?.withTintColor(
172+
self.foregroundColor.uiColor,
173+
renderingMode: .alwaysOriginal
174+
)
175+
case .local(let name, let bundle):
176+
return UIImage(named: name, in: bundle, compatibleWith: nil)
177+
}
178+
}
179+
}
180+
124181
// MARK: UIKit Helpers
125182

126183
extension ButtonVM {
@@ -141,10 +198,23 @@ extension ButtonVM {
141198

142199
return .init(width: width, height: self.height)
143200
}
144-
func shouldUpdateSize(_ oldModel: Self?) -> Bool {
145-
return self.size != oldModel?.size
146-
|| self.font != oldModel?.font
147-
|| self.isFullWidth != oldModel?.isFullWidth
201+
func shouldUpdateImagePosition(_ oldModel: Self?) -> Bool {
202+
guard let oldModel else { return true }
203+
return self.imageLocation != oldModel.imageLocation
204+
}
205+
func shouldUpdateImageSize(_ oldModel: Self?) -> Bool {
206+
guard let oldModel else { return true }
207+
return self.imageSide != oldModel.imageSide
208+
}
209+
func shouldRecalculateSize(_ oldModel: Self?) -> Bool {
210+
guard let oldModel else { return true }
211+
return self.size != oldModel.size
212+
|| self.font != oldModel.font
213+
|| self.isFullWidth != oldModel.isFullWidth
214+
|| self.isLoading != oldModel.isLoading
215+
|| self.imageSrc != oldModel.imageSrc
216+
|| self.contentSpacing != oldModel.contentSpacing
217+
|| self.title != oldModel.title
148218
}
149219
}
150220

Sources/ComponentsKit/Components/Button/SUButton.swift

+67-15
Original file line numberDiff line numberDiff line change
@@ -29,26 +29,78 @@ public struct SUButton: View {
2929
// MARK: Body
3030

3131
public var body: some View {
32-
Button(self.model.title, action: self.action)
33-
.buttonStyle(CustomButtonStyle(model: self.model))
34-
.simultaneousGesture(DragGesture(minimumDistance: 0.0)
35-
.onChanged { _ in
36-
self.isPressed = true
37-
}
38-
.onEnded { _ in
39-
self.isPressed = false
40-
}
41-
)
42-
.disabled(!self.model.isEnabled)
43-
.scaleEffect(
44-
self.isPressed ? self.model.animationScale.value : 1,
45-
anchor: .center
46-
)
32+
Button(action: self.action) {
33+
HStack(spacing: self.model.contentSpacing) {
34+
self.content
35+
}
36+
}
37+
.buttonStyle(CustomButtonStyle(model: self.model))
38+
.simultaneousGesture(DragGesture(minimumDistance: 0.0)
39+
.onChanged { _ in
40+
self.isPressed = true
41+
}
42+
.onEnded { _ in
43+
self.isPressed = false
44+
}
45+
)
46+
.disabled(!self.model.isInteractive)
47+
.scaleEffect(
48+
self.isPressed ? self.model.animationScale.value : 1,
49+
anchor: .center
50+
)
51+
}
52+
53+
@ViewBuilder
54+
private var content: some View {
55+
switch (self.model.isLoading, self.model.image, self.model.imageLocation) {
56+
case (true, _, _) where self.model.title.isEmpty:
57+
SULoading(model: self.model.preferredLoadingVM)
58+
case (true, _, _):
59+
SULoading(model: self.model.preferredLoadingVM)
60+
Text(self.model.title)
61+
case (false, let uiImage?, .leading) where self.model.title.isEmpty:
62+
ButtonImageView(image: uiImage)
63+
.frame(width: self.model.imageSide, height: self.model.imageSide)
64+
case (false, let uiImage?, .leading):
65+
ButtonImageView(image: uiImage)
66+
.frame(width: self.model.imageSide, height: self.model.imageSide)
67+
Text(self.model.title)
68+
case (false, let uiImage?, .trailing) where self.model.title.isEmpty:
69+
ButtonImageView(image: uiImage)
70+
.frame(width: self.model.imageSide, height: self.model.imageSide)
71+
case (false, let uiImage?, .trailing):
72+
Text(self.model.title)
73+
ButtonImageView(image: uiImage)
74+
.frame(width: self.model.imageSide, height: self.model.imageSide)
75+
case (false, _, _):
76+
Text(self.model.title)
77+
}
4778
}
4879
}
4980

5081
// MARK: - Helpers
5182

83+
private struct ButtonImageView: UIViewRepresentable {
84+
class InternalImageView: UIImageView {
85+
override var intrinsicContentSize: CGSize {
86+
return .zero
87+
}
88+
}
89+
90+
let image: UIImage
91+
92+
func makeUIView(context: Context) -> UIImageView {
93+
let imageView = InternalImageView()
94+
imageView.image = self.image
95+
imageView.contentMode = .scaleAspectFit
96+
return imageView
97+
}
98+
99+
func updateUIView(_ imageView: UIImageView, context: Context) {
100+
imageView.image = self.image
101+
}
102+
}
103+
52104
private struct CustomButtonStyle: SwiftUI.ButtonStyle {
53105
let model: ButtonVM
54106

0 commit comments

Comments
 (0)