From 2a4aa4df0a815a27c6d5645c945bee48c6061b54 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 5 Feb 2026 17:19:24 +0800 Subject: [PATCH 01/10] fix: pass completion closures to methods that preset uk modals --- .../Components/Modal/UIKit/UKBottomModalController.swift | 2 +- .../Components/Modal/UIKit/UKCenterModalController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ComponentsKit/Components/Modal/UIKit/UKBottomModalController.swift b/Sources/ComponentsKit/Components/Modal/UIKit/UKBottomModalController.swift index db5b9a10..49ef36d1 100644 --- a/Sources/ComponentsKit/Components/Modal/UIKit/UKBottomModalController.swift +++ b/Sources/ComponentsKit/Components/Modal/UIKit/UKBottomModalController.swift @@ -151,6 +151,6 @@ extension UIViewController { animated: Bool, completion: (() -> Void)? = nil ) { - self.present(vc as UIViewController, animated: false) + self.present(vc as UIViewController, animated: false, completion: completion) } } diff --git a/Sources/ComponentsKit/Components/Modal/UIKit/UKCenterModalController.swift b/Sources/ComponentsKit/Components/Modal/UIKit/UKCenterModalController.swift index c31e8704..fffdc564 100644 --- a/Sources/ComponentsKit/Components/Modal/UIKit/UKCenterModalController.swift +++ b/Sources/ComponentsKit/Components/Modal/UIKit/UKCenterModalController.swift @@ -122,6 +122,6 @@ extension UIViewController { animated: Bool, completion: (() -> Void)? = nil ) { - self.present(vc as UIViewController, animated: false) + self.present(vc as UIViewController, animated: false, completion: completion) } } From d9410bf54a5e260c573d8908149aa849ca6e565d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 5 Feb 2026 17:26:18 +0800 Subject: [PATCH 02/10] remove loading style --- .../PreviewPages/LoadingPreview.swift | 3 --- .../Loading/Models/LoadingStyle.swift | 8 ------- .../Components/Loading/Models/LoadingVM.swift | 22 ++++++------------- 3 files changed, 7 insertions(+), 26 deletions(-) delete mode 100644 Sources/ComponentsKit/Components/Loading/Models/LoadingStyle.swift diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift index 3ee062f6..783f2972 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift @@ -21,9 +21,6 @@ struct LoadingPreview: View { Text("Custom: 6px").tag(CGFloat(6.0)) } SizePicker(selection: self.$model.size) - Picker("Style", selection: self.$model.style) { - Text("Spinner").tag(LoadingVM.Style.spinner) - } } } } diff --git a/Sources/ComponentsKit/Components/Loading/Models/LoadingStyle.swift b/Sources/ComponentsKit/Components/Loading/Models/LoadingStyle.swift deleted file mode 100644 index 71031529..00000000 --- a/Sources/ComponentsKit/Components/Loading/Models/LoadingStyle.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -extension LoadingVM { - /// The loading appearance style. - public enum Style { - case spinner - } -} diff --git a/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift b/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift index c9f7f30b..2d370009 100644 --- a/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift +++ b/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift @@ -17,11 +17,6 @@ public struct LoadingVM: ComponentVM { /// Defaults to `.medium`. public var size: ComponentSize = .medium - /// The style of the loading indicator (e.g., spinner, bar). - /// - /// Defaults to `.spinner`. - public var style: Style = .spinner - /// Initializes a new instance of `LoadingVM` with default values. public init() {} } @@ -33,16 +28,13 @@ extension LoadingVM { return self.lineWidth ?? max(self.preferredSize.width / 8, 2) } var preferredSize: CGSize { - switch self.style { - case .spinner: - switch self.size { - case .small: - return .init(width: 24, height: 24) - case .medium: - return .init(width: 36, height: 36) - case .large: - return .init(width: 48, height: 48) - } + switch self.size { + case .small: + return .init(width: 24, height: 24) + case .medium: + return .init(width: 36, height: 36) + case .large: + return .init(width: 48, height: 48) } } var radius: CGFloat { From acea575e60162fec58687bd6b890db2829c6ca15 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 5 Feb 2026 19:11:32 +0800 Subject: [PATCH 03/10] make the size in loader optional --- .../Helpers/PreviewPickers.swift | 13 +++++++ .../PreviewPages/LoadingPreview.swift | 2 +- .../Components/Loading/Models/LoadingVM.swift | 37 ++++++++++--------- .../Components/Loading/SULoading.swift | 33 ++++++++++------- .../Components/Loading/UKLoading.swift | 25 ++++++------- 5 files changed, 64 insertions(+), 46 deletions(-) diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift index e97783d9..e2b2da01 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift @@ -282,6 +282,19 @@ struct SizePicker: View { } } +struct OptionalSizePicker: View { + @Binding var selection: ComponentSize? + + var body: some View { + Picker("Size", selection: self.$selection) { + Text("Nil").tag(Optional.none) + Text("Small").tag(ComponentSize.small) + Text("Medium").tag(ComponentSize.medium) + Text("Large").tag(ComponentSize.large) + } + } +} + // MARK: - SubmitTypePicker struct SubmitTypePicker: View { diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift index 783f2972..905f74d8 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift @@ -20,7 +20,7 @@ struct LoadingPreview: View { Text("Default").tag(Optional.none) Text("Custom: 6px").tag(CGFloat(6.0)) } - SizePicker(selection: self.$model.size) + OptionalSizePicker(selection: self.$model.size) } } } diff --git a/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift b/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift index 2d370009..7ea60e41 100644 --- a/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift +++ b/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift @@ -14,8 +14,13 @@ public struct LoadingVM: ComponentVM { /// The predefined size of the loading indicator. /// + /// If nil, the loader is intended to expand to the available space provided by + /// the surrounding layout: + /// - In SwiftUI, constrain it with .frame(...). + /// - In UIKit, constrain it with Auto Layout. + /// /// Defaults to `.medium`. - public var size: ComponentSize = .medium + public var size: ComponentSize? = .medium /// Initializes a new instance of `LoadingVM` with default values. public init() {} @@ -25,10 +30,20 @@ public struct LoadingVM: ComponentVM { extension LoadingVM { var loadingLineWidth: CGFloat { - return self.lineWidth ?? max(self.preferredSize.width / 8, 2) + if let lineWidth { + return lineWidth + } else if let width = self.preferredSize?.width { + return max(width / 8, 2) + } else { + return 3 + } } - var preferredSize: CGSize { - switch self.size { + var preferredSize: CGSize? { + guard let size else { + return nil + } + + switch size { case .small: return .init(width: 24, height: 24) case .medium: @@ -37,9 +52,6 @@ extension LoadingVM { return .init(width: 48, height: 48) } } - var radius: CGFloat { - return self.preferredSize.height / 2 - self.loadingLineWidth / 2 - } } // MARK: UIKit Helpers @@ -49,14 +61,3 @@ extension LoadingVM { return self.size != oldModel.size || self.lineWidth != oldModel.lineWidth } } - -// MARK: SwiftUI Helpers - -extension LoadingVM { - var center: CGPoint { - return .init( - x: self.preferredSize.width / 2, - y: self.preferredSize.height / 2 - ) - } -} diff --git a/Sources/ComponentsKit/Components/Loading/SULoading.swift b/Sources/ComponentsKit/Components/Loading/SULoading.swift index 553e7644..d0b96d47 100644 --- a/Sources/ComponentsKit/Components/Loading/SULoading.swift +++ b/Sources/ComponentsKit/Components/Loading/SULoading.swift @@ -22,15 +22,19 @@ public struct SULoading: View { // MARK: Body public var body: some View { - Path { path in - path.addArc( - center: self.model.center, - radius: self.model.radius, - startAngle: .radians(0), - endAngle: .radians(2 * .pi), - clockwise: true - ) - } + GeometryReader { geometry in + Path { path in + path.addArc( + center: .init( + x: geometry.size.width / 2, + y: geometry.size.height / 2 + ), + radius: min(geometry.size.width, geometry.size.height) / 2 - self.model.loadingLineWidth, + startAngle: .radians(0), + endAngle: .radians(2 * .pi), + clockwise: true + ) + } .trim(from: 0, to: 0.75) .stroke( self.model.color.main.color, @@ -47,15 +51,16 @@ public struct SULoading: View { .repeatForever(autoreverses: false), value: self.rotationAngle ) - .frame( - width: self.model.preferredSize.width, - height: self.model.preferredSize.height, - alignment: .center - ) .onAppear { DispatchQueue.main.async { self.rotationAngle = 2 * .pi } } + } + .frame( + width: self.model.preferredSize?.width, + height: self.model.preferredSize?.height, + alignment: .center + ) } } diff --git a/Sources/ComponentsKit/Components/Loading/UKLoading.swift b/Sources/ComponentsKit/Components/Loading/UKLoading.swift index bd4adecd..502c3ce6 100644 --- a/Sources/ComponentsKit/Components/Loading/UKLoading.swift +++ b/Sources/ComponentsKit/Components/Loading/UKLoading.swift @@ -64,12 +64,6 @@ open class UKLoading: UIView, UKComponent { self.addSpinnerAnimation() - NotificationCenter.default.addObserver( - self, - selector: #selector(self.handleAppWillMoveToBackground), - name: UIApplication.willResignActiveNotification, - object: nil - ) NotificationCenter.default.addObserver( self, selector: #selector(self.handleAppMovedFromBackground), @@ -92,9 +86,6 @@ open class UKLoading: UIView, UKComponent { self.shapeLayer.strokeEnd = 0.75 } - @objc private func handleAppWillMoveToBackground() { - self.shapeLayer.removeAllAnimations() - } @objc private func handleAppMovedFromBackground() { self.addSpinnerAnimation() } @@ -116,8 +107,8 @@ open class UKLoading: UIView, UKComponent { } private func updateShapePath() { - let radius = self.model.preferredSize.height / 2 - self.shapeLayer.lineWidth / 2 let center = CGPoint(x: self.bounds.midX, y: self.bounds.midY) + let radius = min(self.bounds.height, self.bounds.width) / 2 - self.model.loadingLineWidth self.shapeLayer.path = UIBezierPath( arcCenter: center, radius: radius, @@ -132,9 +123,11 @@ open class UKLoading: UIView, UKComponent { open override func layoutSubviews() { super.layoutSubviews() - // Adjust the layer's frame to fit within the view's bounds + CATransaction.begin() + CATransaction.setDisableActions(true) self.shapeLayer.frame = self.bounds self.updateShapePath() + CATransaction.commit() if self.isVisible { self.addSpinnerAnimation() @@ -144,7 +137,7 @@ open class UKLoading: UIView, UKComponent { // MARK: UIView methods open override func sizeThatFits(_ size: CGSize) -> CGSize { - let preferredSize = self.model.preferredSize + let preferredSize = self.model.preferredSize ?? size return .init( width: min(preferredSize.width, size.width), height: min(preferredSize.height, size.height) @@ -161,13 +154,19 @@ open class UKLoading: UIView, UKComponent { // MARK: Helpers private func addSpinnerAnimation() { + let animationKey = "rotationAnimation" + + guard self.shapeLayer.animation(forKey: animationKey).isNil else { + return + } + let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z") rotationAnimation.fromValue = 0 rotationAnimation.toValue = CGFloat.pi * 2 rotationAnimation.duration = 1.0 rotationAnimation.repeatCount = .infinity rotationAnimation.timingFunction = CAMediaTimingFunction(name: .linear) - self.shapeLayer.add(rotationAnimation, forKey: "rotationAnimation") + self.shapeLayer.add(rotationAnimation, forKey: animationKey) } private func handleTraitChanges() { From f47059fd992ccd7c05448cbee11ff5c30a280a15 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 5 Feb 2026 19:18:18 +0800 Subject: [PATCH 04/10] add line cap param to loading --- .../Helpers/PreviewPickers.swift | 13 +++++++ .../CircularProgressPreview.swift | 5 +-- .../PreviewPages/LoadingPreview.swift | 1 + .../Models/CircularProgressLineCap.swift | 38 ------------------- .../Components/Loading/Models/LoadingVM.swift | 3 ++ .../Components/Loading/SULoading.swift | 2 +- .../Components/Loading/UKLoading.swift | 3 +- .../ComponentsKit/Shared/Types/LineCap.swift | 36 ++++++++++++++++++ 8 files changed, 57 insertions(+), 44 deletions(-) delete mode 100644 Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressLineCap.swift create mode 100644 Sources/ComponentsKit/Shared/Types/LineCap.swift diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift index e2b2da01..bf46eec7 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/Helpers/PreviewPickers.swift @@ -254,6 +254,19 @@ struct KeyboardTypePicker: View { } } +// MARK: - LineCap + +struct LineCapPicker: View { + @Binding var selection: LineCap + + var body: some View { + Picker("Line Cap", selection: self.$selection) { + Text("Rounded").tag(LineCap.rounded) + Text("Square").tag(LineCap.square) + } + } +} + // MARK: - OverlayStylePicker struct OverlayStylePicker: View { diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift index ef98cd9d..defdae71 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift @@ -29,10 +29,7 @@ struct CircularProgressPreview: View { Form { ComponentColorPicker(selection: self.$model.color) CaptionFontPicker(selection: self.$model.font) - Picker("Line Cap", selection: self.$model.lineCap) { - Text("Rounded").tag(CircularProgressVM.LineCap.rounded) - Text("Square").tag(CircularProgressVM.LineCap.square) - } + LineCapPicker(selection: self.$model.lineCap) Picker("Line Width", selection: self.$model.lineWidth) { Text("Default").tag(Optional.none) Text("2").tag(Optional.some(2)) diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift index 905f74d8..c8003bb3 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/LoadingPreview.swift @@ -16,6 +16,7 @@ struct LoadingPreview: View { } Form { ComponentColorPicker(selection: self.$model.color) + LineCapPicker(selection: self.$model.lineCap) Picker("Line Width", selection: self.$model.lineWidth) { Text("Default").tag(Optional.none) Text("Custom: 6px").tag(CGFloat(6.0)) diff --git a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressLineCap.swift b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressLineCap.swift deleted file mode 100644 index 72a86b8c..00000000 --- a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressLineCap.swift +++ /dev/null @@ -1,38 +0,0 @@ -import SwiftUI -import UIKit - -extension CircularProgressVM { - /// Defines the style of line endings. - public enum LineCap { - /// The line ends with a semicircular arc that extends beyond the endpoint, creating a rounded appearance. - case rounded - /// The line ends exactly at the endpoint with a flat edge. - case square - } -} - -// MARK: - UIKit Helpers - -extension CircularProgressVM.LineCap { - var shapeLayerLineCap: CAShapeLayerLineCap { - switch self { - case .rounded: - return .round - case .square: - return .butt - } - } -} - -// MARK: - SwiftUI Helpers - -extension CircularProgressVM.LineCap { - var cgLineCap: CGLineCap { - switch self { - case .rounded: - return .round - case .square: - return .butt - } - } -} diff --git a/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift b/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift index 7ea60e41..fc3d11a3 100644 --- a/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift +++ b/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift @@ -7,6 +7,9 @@ public struct LoadingVM: ComponentVM { /// Defaults to `.accent`. public var color: ComponentColor = .accent + /// The style of line endings. + public var lineCap: LineCap = .rounded + /// The width of the lines used in the loading indicator. /// /// If not provided, the line width is automatically adjusted based on the size. diff --git a/Sources/ComponentsKit/Components/Loading/SULoading.swift b/Sources/ComponentsKit/Components/Loading/SULoading.swift index d0b96d47..206d03aa 100644 --- a/Sources/ComponentsKit/Components/Loading/SULoading.swift +++ b/Sources/ComponentsKit/Components/Loading/SULoading.swift @@ -40,7 +40,7 @@ public struct SULoading: View { self.model.color.main.color, style: StrokeStyle( lineWidth: self.model.loadingLineWidth, - lineCap: .round, + lineCap: self.model.lineCap.cgLineCap, lineJoin: .round, miterLimit: 0 ) diff --git a/Sources/ComponentsKit/Components/Loading/UKLoading.swift b/Sources/ComponentsKit/Components/Loading/UKLoading.swift index 502c3ce6..040a1d0f 100644 --- a/Sources/ComponentsKit/Components/Loading/UKLoading.swift +++ b/Sources/ComponentsKit/Components/Loading/UKLoading.swift @@ -82,7 +82,7 @@ open class UKLoading: UIView, UKComponent { self.shapeLayer.lineWidth = self.model.loadingLineWidth self.shapeLayer.strokeColor = self.model.color.main.uiColor.cgColor self.shapeLayer.fillColor = UIColor.clear.cgColor - self.shapeLayer.lineCap = .round + self.shapeLayer.lineCap = self.model.lineCap.shapeLayerLineCap self.shapeLayer.strokeEnd = 0.75 } @@ -97,6 +97,7 @@ open class UKLoading: UIView, UKComponent { self.shapeLayer.lineWidth = self.model.loadingLineWidth self.shapeLayer.strokeColor = self.model.color.main.uiColor.cgColor + self.shapeLayer.lineCap = self.model.lineCap.shapeLayerLineCap if self.model.shouldUpdateShapePath(oldModel) { self.updateShapePath() diff --git a/Sources/ComponentsKit/Shared/Types/LineCap.swift b/Sources/ComponentsKit/Shared/Types/LineCap.swift new file mode 100644 index 00000000..833aa40c --- /dev/null +++ b/Sources/ComponentsKit/Shared/Types/LineCap.swift @@ -0,0 +1,36 @@ +import SwiftUI +import UIKit + +/// Defines the style of line endings. +public enum LineCap { + /// The line ends with a semicircular arc that extends beyond the endpoint, creating a rounded appearance. + case rounded + /// The line ends exactly at the endpoint with a flat edge. + case square +} + +// MARK: - UIKit Helpers + +extension LineCap { + var shapeLayerLineCap: CAShapeLayerLineCap { + switch self { + case .rounded: + return .round + case .square: + return .butt + } + } +} + +// MARK: - SwiftUI Helpers + +extension LineCap { + var cgLineCap: CGLineCap { + switch self { + case .rounded: + return .round + case .square: + return .butt + } + } +} From 2c558b2bfc78919e7889ff05e5df1e0851ac6f07 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 6 Feb 2026 13:09:22 +0800 Subject: [PATCH 05/10] make size in circular progress view option --- .../CircularProgressPreview.swift | 2 +- .../Models/CircularProgressVM.swift | 36 ++++++--- .../CircularProgress/SUCircularProgress.swift | 74 +++++++++---------- .../CircularProgress/UKCircularProgress.swift | 17 ++--- 4 files changed, 68 insertions(+), 61 deletions(-) diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift index defdae71..0a23ac06 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/CircularProgressPreview.swift @@ -40,7 +40,7 @@ struct CircularProgressPreview: View { Text("Circle").tag(CircularProgressVM.Shape.circle) Text("Arc").tag(CircularProgressVM.Shape.arc) } - SizePicker(selection: self.$model.size) + OptionalSizePicker(selection: self.$model.size) } .onReceive(self.timer) { _ in if self.model.currentValue < self.model.maxValue { diff --git a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift index 3422d75b..d444ae12 100644 --- a/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift +++ b/Sources/ComponentsKit/Components/CircularProgress/Models/CircularProgressVM.swift @@ -41,8 +41,13 @@ public struct CircularProgressVM: ComponentVM { /// The size of the circular progress. /// + /// If nil, the view is intended to expand to the available space provided by + /// the surrounding layout: + /// - In SwiftUI, constrain it with .frame(...). + /// - In UIKit, constrain it with Auto Layout. + /// /// Defaults to `.medium`. - public var size: ComponentSize = .medium + public var size: ComponentSize? = .medium /// Initializes a new instance of `CircularProgressVM` with default values. public init() {} @@ -55,10 +60,20 @@ extension CircularProgressVM { return 0.2 } var circularLineWidth: CGFloat { - return self.lineWidth ?? max(self.preferredSize.width / 8, 2) + if let lineWidth { + return lineWidth + } else if let width = self.preferredSize?.width { + return max(width / 8, 2) + } else { + return 3 + } } - var preferredSize: CGSize { - switch self.size { + var preferredSize: CGSize? { + guard let size else { + return nil + } + + switch size { case .small: return CGSize(width: 48, height: 48) case .medium: @@ -67,14 +82,11 @@ extension CircularProgressVM { return CGSize(width: 80, height: 80) } } - var radius: CGFloat { - return self.preferredSize.height / 2 - self.circularLineWidth / 2 + func radius(size: CGSize) -> CGFloat { + return min(size.width, size.height) / 2 - self.circularLineWidth / 2 } - var center: CGPoint { - return .init( - x: self.preferredSize.width / 2, - y: self.preferredSize.height / 2 - ) + func center(size: CGSize) -> CGPoint { + return .init(x: size.width / 2, y: size.height / 2) } var startAngle: CGFloat { switch self.shape { @@ -99,7 +111,7 @@ extension CircularProgressVM { switch self.size { case .small: return .smCaption - case .medium: + case .medium, .none: return .mdCaption case .large: return .lgCaption diff --git a/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift b/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift index 392e4438..dee6ad47 100644 --- a/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift +++ b/Sources/ComponentsKit/Components/CircularProgress/SUCircularProgress.swift @@ -40,49 +40,47 @@ public struct SUCircularProgress: View { public var body: some View { ZStack { - // Background part - Path { path in - path.addArc( - center: self.model.center, - radius: self.model.radius, - startAngle: .radians(self.model.startAngle), - endAngle: .radians(self.model.endAngle), - clockwise: false + GeometryReader { geometry in + // Background part + Path { path in + path.addArc( + center: self.model.center(size: geometry.size), + radius: self.model.radius(size: geometry.size), + startAngle: .radians(self.model.startAngle), + endAngle: .radians(self.model.endAngle), + clockwise: false + ) + } + .stroke( + self.model.color.background.color, + style: StrokeStyle( + lineWidth: self.model.circularLineWidth, + lineCap: self.model.lineCap.cgLineCap + ) ) - } - .stroke( - self.model.color.background.color, - style: StrokeStyle( - lineWidth: self.model.circularLineWidth, - lineCap: self.model.lineCap.cgLineCap - ) - ) - .frame( - width: self.model.preferredSize.width, - height: self.model.preferredSize.height - ) - // Foreground part - Path { path in - path.addArc( - center: self.model.center, - radius: self.model.radius, - startAngle: .radians(self.model.startAngle), - endAngle: .radians(self.model.endAngle), - clockwise: false + // Foreground part + Path { path in + path.addArc( + center: self.model.center(size: geometry.size), + radius: self.model.radius(size: geometry.size), + startAngle: .radians(self.model.startAngle), + endAngle: .radians(self.model.endAngle), + clockwise: false + ) + } + .trim(from: 0, to: self.progress) + .stroke( + self.model.color.main.color, + style: StrokeStyle( + lineWidth: self.model.circularLineWidth, + lineCap: self.model.lineCap.cgLineCap + ) ) } - .trim(from: 0, to: self.progress) - .stroke( - self.model.color.main.color, - style: StrokeStyle( - lineWidth: self.model.circularLineWidth, - lineCap: self.model.lineCap.cgLineCap - ) - ) .frame( - width: self.model.preferredSize.width, - height: self.model.preferredSize.height + width: self.model.preferredSize?.width, + height: self.model.preferredSize?.height ) // Optional label diff --git a/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift b/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift index 193d2a11..c1777454 100644 --- a/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift +++ b/Sources/ComponentsKit/Components/CircularProgress/UKCircularProgress.swift @@ -37,7 +37,7 @@ open class UKCircularProgress: UIView, UKComponent { // MARK: - UIView Properties open override var intrinsicContentSize: CGSize { - return self.model.preferredSize + return self.sizeThatFits(UIView.layoutFittingExpandedSize) } // MARK: - Initialization @@ -130,12 +130,9 @@ open class UKCircularProgress: UIView, UKComponent { } private func updateShapePaths() { - let center = CGPoint(x: self.bounds.midX, y: self.bounds.midY) - let minSide = min(self.bounds.width, self.bounds.height) - let radius = (minSide - self.model.circularLineWidth) / 2 let circlePath = UIBezierPath( - arcCenter: center, - radius: radius, + arcCenter: self.model.center(size: self.bounds.size), + radius: self.model.radius(size: self.bounds.size), startAngle: self.model.startAngle, endAngle: self.model.endAngle, clockwise: true @@ -171,10 +168,10 @@ open class UKCircularProgress: UIView, UKComponent { // MARK: - UIView Methods open override func sizeThatFits(_ size: CGSize) -> CGSize { - let preferred = self.model.preferredSize - return CGSize( - width: min(size.width, preferred.width), - height: min(size.height, preferred.height) + let preferredSize = self.model.preferredSize ?? size + return .init( + width: min(preferredSize.width, size.width), + height: min(preferredSize.height, size.height) ) } From 0f04cf49b33fccd168b3e18b904e1af11bf0b8ab Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 6 Feb 2026 13:13:33 +0800 Subject: [PATCH 06/10] add center and radius helpers to loading model --- .../Components/Loading/Models/LoadingVM.swift | 6 ++++++ Sources/ComponentsKit/Components/Loading/SULoading.swift | 7 ++----- Sources/ComponentsKit/Components/Loading/UKLoading.swift | 6 ++---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift b/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift index fc3d11a3..d92606de 100644 --- a/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift +++ b/Sources/ComponentsKit/Components/Loading/Models/LoadingVM.swift @@ -55,6 +55,12 @@ extension LoadingVM { return .init(width: 48, height: 48) } } + func radius(size: CGSize) -> CGFloat { + return min(size.width, size.height) / 2 - self.loadingLineWidth / 2 + } + func center(size: CGSize) -> CGPoint { + return .init(x: size.width / 2, y: size.height / 2) + } } // MARK: UIKit Helpers diff --git a/Sources/ComponentsKit/Components/Loading/SULoading.swift b/Sources/ComponentsKit/Components/Loading/SULoading.swift index 206d03aa..2133d22e 100644 --- a/Sources/ComponentsKit/Components/Loading/SULoading.swift +++ b/Sources/ComponentsKit/Components/Loading/SULoading.swift @@ -25,11 +25,8 @@ public struct SULoading: View { GeometryReader { geometry in Path { path in path.addArc( - center: .init( - x: geometry.size.width / 2, - y: geometry.size.height / 2 - ), - radius: min(geometry.size.width, geometry.size.height) / 2 - self.model.loadingLineWidth, + center: self.model.center(size: geometry.size), + radius: self.model.radius(size: geometry.size), startAngle: .radians(0), endAngle: .radians(2 * .pi), clockwise: true diff --git a/Sources/ComponentsKit/Components/Loading/UKLoading.swift b/Sources/ComponentsKit/Components/Loading/UKLoading.swift index 040a1d0f..9a9f0e27 100644 --- a/Sources/ComponentsKit/Components/Loading/UKLoading.swift +++ b/Sources/ComponentsKit/Components/Loading/UKLoading.swift @@ -108,11 +108,9 @@ open class UKLoading: UIView, UKComponent { } private func updateShapePath() { - let center = CGPoint(x: self.bounds.midX, y: self.bounds.midY) - let radius = min(self.bounds.height, self.bounds.width) / 2 - self.model.loadingLineWidth self.shapeLayer.path = UIBezierPath( - arcCenter: center, - radius: radius, + arcCenter: self.model.center(size: self.bounds.size), + radius: self.model.radius(size: self.bounds.size), startAngle: 0, endAngle: 2 * .pi, clockwise: true From 84c5fe158f82c6e678dee442621bd1c72acc86fe Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 6 Feb 2026 13:18:34 +0800 Subject: [PATCH 07/10] fix typo --- .../PreviewPages/SegmentedControlPreview.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SegmentedControlPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SegmentedControlPreview.swift index 6566bdeb..86e66339 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SegmentedControlPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/SegmentedControlPreview.swift @@ -18,7 +18,7 @@ struct SegmentedControlPreview: View { $0.title = "iPad" }, .init(id: .mac) { - $0.title = "Mackbook" + $0.title = "Macbook" } ] } From cc8603a0d5670fe0d812abb53bf9a997b46f78d9 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 6 Feb 2026 15:00:48 +0800 Subject: [PATCH 08/10] add universal image --- .../{Types => Image}/ImageRenderingMode.swift | 10 -- .../Shared/Image/UniversalImage.swift | 95 +++++++++++++++++++ 2 files changed, 95 insertions(+), 10 deletions(-) rename Sources/ComponentsKit/Shared/{Types => Image}/ImageRenderingMode.swift (80%) create mode 100644 Sources/ComponentsKit/Shared/Image/UniversalImage.swift diff --git a/Sources/ComponentsKit/Shared/Types/ImageRenderingMode.swift b/Sources/ComponentsKit/Shared/Image/ImageRenderingMode.swift similarity index 80% rename from Sources/ComponentsKit/Shared/Types/ImageRenderingMode.swift rename to Sources/ComponentsKit/Shared/Image/ImageRenderingMode.swift index b4b8f662..4cf973bb 100644 --- a/Sources/ComponentsKit/Shared/Types/ImageRenderingMode.swift +++ b/Sources/ComponentsKit/Shared/Image/ImageRenderingMode.swift @@ -26,16 +26,6 @@ extension ImageRenderingMode { } } -extension UIImage { - func withRenderingMode(_ mode: ImageRenderingMode?) -> UIImage { - if let mode { - return self.withRenderingMode(mode.uiImageRenderingMode) - } else { - return self - } - } -} - // MARK: - SwiftUI Helpers extension ImageRenderingMode { diff --git a/Sources/ComponentsKit/Shared/Image/UniversalImage.swift b/Sources/ComponentsKit/Shared/Image/UniversalImage.swift new file mode 100644 index 00000000..a30a4443 --- /dev/null +++ b/Sources/ComponentsKit/Shared/Image/UniversalImage.swift @@ -0,0 +1,95 @@ +import SwiftUI +import UIKit + +/// A lightweight, platform-agnostic container for image sources. +/// +/// UniversalImage abstracts how an image is specified without committing +/// to a particular rendering type (e.g., `UIImage` on UIKit or `Image` on SwiftUI). +/// It supports two kinds of sources: +/// - SF Symbols by name +/// - Asset catalog images by name (optionally scoping lookup to a specific bundle) +/// +/// This type is intended to be carried in view models and resolved into +/// platform-specific images by higher-level helpers or components. +public struct UniversalImage: Hashable { + /// Internal representation of the image source. + private enum ImageRepresentable: Hashable { + /// An SF Symbol identified by its system name. + case sfSymbol(String) + /// An asset catalog image identified by name and an optional bundle. + case asset(String, Bundle?) + } + + /// The underlying image source. + private let internalImage: ImageRepresentable + /// Indicates how images are rendered. + private var renderingMode: ImageRenderingMode? + + /// Creates a new instance with provided image and mode. + private init(image: ImageRepresentable, mode: ImageRenderingMode) { + self.internalImage = image + self.renderingMode = mode + } + + /// Creates an image that references an SF Symbol. + /// + /// - Parameter sfSymbol: The system symbol name, such as `"star.fill"`. + /// + /// Notes: + /// - Availability and appearance vary by platform and OS version. + /// - Rendering mode and weight should be applied when converting to a platform image. + public init(systemName name: String) { + self.internalImage = .sfSymbol(name) + } + + /// Creates an image that references an asset catalog image. + /// + /// - Parameters: + /// - name: The asset name. + /// - bundle: The bundle to search. Pass `nil` to search the main bundle. + /// + /// Use this when your image is stored in an asset catalog. If you are distributing + /// a framework or Swift package, provide the bundle that contains the asset. + public init(_ name: String, bundle: Bundle? = nil) { + self.internalImage = .asset(name, bundle) + } +} + +// MARK: - Helpers + +extension UniversalImage { + /// Resolves the universal image into a UIKit `UIImage`. + public var uiImage: UIImage? { + let image = switch self.internalImage { + case .sfSymbol(let name): + UIImage(systemName: name) + case .asset(let name, let bundle): + UIImage(named: name, in: bundle, with: nil) + } + if let renderingMode { + return image?.withRenderingMode(renderingMode.uiImageRenderingMode) + } else { + return image + } + } + + /// Resolves the universal image into a SwiftUI `Image`. + public var image: Image { + let image = switch self.internalImage { + case .sfSymbol(let name): + Image(systemName: name) + case .asset(let name, let bundle): + Image(name, bundle: bundle) + } + if let renderingMode { + return image.renderingMode(renderingMode.imageRenderingModel) + } else { + return image + } + } + + /// Returns a new version of the image that uses the specified rendering mode. + public func withRenderingMode(_ mode: ImageRenderingMode) -> Self { + return Self(image: self.internalImage, mode: mode) + } +} From cf35496ae154e2ae1f1a6c2956c89f9683a902c0 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 6 Feb 2026 15:04:47 +0800 Subject: [PATCH 09/10] deprecate `imageSrc` and `imageRenderingMode`, add `image` param in `ButtonVM` --- .../PreviewPages/ButtonPreview.swift | 29 ++++--- .../Components/Button/Models/ButtonVM.swift | 25 +++++-- .../Components/Button/SUButton.swift | 75 +++++++++---------- .../Components/Button/UKButton.swift | 4 +- 4 files changed, 74 insertions(+), 59 deletions(-) diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift index 5a9ce2bd..430476a8 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/ButtonPreview.swift @@ -35,15 +35,26 @@ struct ButtonPreview: View { Text("Leading").tag(ButtonVM.ImageLocation.leading) Text("Trailing").tag(ButtonVM.ImageLocation.trailing) } - Picker("Image Rendering Mode", selection: self.$model.imageRenderingMode) { - Text("Default").tag(Optional.none) - Text("Template").tag(ImageRenderingMode.template) - Text("Original").tag(ImageRenderingMode.original) - } - Picker("Image Source", selection: self.$model.imageSrc) { - Text("SF Symbol").tag(ButtonVM.ImageSource.sfSymbol("camera.fill")) - Text("Local").tag(ButtonVM.ImageSource.local("avatar_placeholder")) - Text("None").tag(Optional.none) + Picker("Image", selection: self.$model.image) { + Text("SF Symbol").tag(UniversalImage(systemName: "camera.fill")) + Text("SF Symbol (Template)").tag( + UniversalImage(systemName: "camera.fill") + .withRenderingMode(.template) + ) + Text("SF Symbol (Original)").tag( + UniversalImage(systemName: "camera.fill") + .withRenderingMode(.original) + ) + Text("Local").tag(UniversalImage("avatar_placeholder")) + Text("Local (Template)").tag( + UniversalImage("avatar_placeholder") + .withRenderingMode(.template) + ) + Text("Local (Original)").tag( + UniversalImage("avatar_placeholder") + .withRenderingMode(.original) + ) + Text("None").tag(Optional.none) } Toggle("Loading", isOn: self.$model.isLoading) Toggle("Show Title", isOn: Binding( diff --git a/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift b/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift index 9303de5e..c42a8865 100644 --- a/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift +++ b/Sources/ComponentsKit/Components/Button/Models/ButtonVM.swift @@ -25,15 +25,20 @@ public struct ButtonVM: ComponentVM { /// If not provided, the font is automatically calculated based on the button's size. public var font: UniversalFont? + /// The image to be displayed. + public var image: UniversalImage? + /// The position of the image relative to the button's title. /// /// Defaults to `.leading`. public var imageLocation: ImageLocation = .leading /// Defines how image is rendered. + @available(*, deprecated, message: "Use `image.withRenderingMode(_:)` instead.") public var imageRenderingMode: ImageRenderingMode? /// The source of the image to be displayed. + @available(*, deprecated, message: "Use `image` instead.") public var imageSrc: ImageSource? /// A Boolean value indicating whether the button is enabled or disabled. @@ -182,25 +187,31 @@ extension ButtonVM { } } } -} + var imageWithLegacyFallback: UniversalImage? { + if let image { return image } -extension ButtonVM { - var image: UIImage? { guard let imageSrc else { return nil } let image = switch imageSrc { case .sfSymbol(let name): - UIImage(systemName: name) + UniversalImage(systemName: name) case .local(let name, let bundle): - UIImage(named: name, in: bundle, compatibleWith: nil) + UniversalImage(name, bundle: bundle) + } + if let imageRenderingMode { + return image.withRenderingMode(imageRenderingMode) + } else { + return image } - return image?.withRenderingMode(self.imageRenderingMode) } } // MARK: UIKit Helpers extension ButtonVM { + var isImageHidden: Bool { + return self.isLoading || self.imageWithLegacyFallback.isNil + } func preferredSize( for contentSize: CGSize, parentWidth: CGFloat? @@ -232,7 +243,7 @@ extension ButtonVM { || self.font != oldModel.font || self.isFullWidth != oldModel.isFullWidth || self.isLoading != oldModel.isLoading - || self.imageSrc != oldModel.imageSrc + || self.imageWithLegacyFallback != oldModel.imageWithLegacyFallback || self.contentSpacing != oldModel.contentSpacing || self.title != oldModel.title } diff --git a/Sources/ComponentsKit/Components/Button/SUButton.swift b/Sources/ComponentsKit/Components/Button/SUButton.swift index 550eb577..ef930ed5 100644 --- a/Sources/ComponentsKit/Components/Button/SUButton.swift +++ b/Sources/ComponentsKit/Components/Button/SUButton.swift @@ -50,38 +50,32 @@ public struct SUButton: View { @ViewBuilder private var content: some View { - switch (self.model.isLoading, self.model.image, self.model.imageLocation) { + switch (self.model.isLoading, self.model.imageWithLegacyFallback, self.model.imageLocation) { case (true, _, _) where self.model.title.isEmpty: SULoading(model: self.model.preferredLoadingVM) case (true, _, _): SULoading(model: self.model.preferredLoadingVM) Text(self.model.title) - case (false, let uiImage?, .leading) where self.model.title.isEmpty: - ButtonImageView( - image: uiImage, - tintColor: self.model.foregroundColor.uiColor + case (false, let image?, _) where self.model.title.isEmpty: + ButtonImage( + image: image, + tintColor: self.model.foregroundColor, + side: self.model.imageSide ) - .frame(width: self.model.imageSide, height: self.model.imageSide) - case (false, let uiImage?, .leading): - ButtonImageView( - image: uiImage, - tintColor: self.model.foregroundColor.uiColor + case (false, let image?, .leading): + ButtonImage( + image: image, + tintColor: self.model.foregroundColor, + side: self.model.imageSide ) - .frame(width: self.model.imageSide, height: self.model.imageSide) Text(self.model.title) - case (false, let uiImage?, .trailing) where self.model.title.isEmpty: - ButtonImageView( - image: uiImage, - tintColor: self.model.foregroundColor.uiColor - ) - .frame(width: self.model.imageSide, height: self.model.imageSide) - case (false, let uiImage?, .trailing): + case (false, let image?, .trailing): Text(self.model.title) - ButtonImageView( - image: uiImage, - tintColor: self.model.foregroundColor.uiColor + ButtonImage( + image: image, + tintColor: self.model.foregroundColor, + side: self.model.imageSide ) - .frame(width: self.model.imageSide, height: self.model.imageSide) case (false, _, _): Text(self.model.title) } @@ -90,28 +84,27 @@ public struct SUButton: View { // MARK: - Helpers -private struct ButtonImageView: UIViewRepresentable { - class InternalImageView: UIImageView { - override var intrinsicContentSize: CGSize { - return .zero - } - } +private struct ButtonImage: View { + let universalImage: UniversalImage + let tintColor: UniversalColor + let side: CGFloat - let image: UIImage - let tintColor: UIColor - - func makeUIView(context: Context) -> UIImageView { - let imageView = InternalImageView() - imageView.image = self.image - imageView.tintColor = self.tintColor - imageView.contentMode = .scaleAspectFit - imageView.isUserInteractionEnabled = true - return imageView + init( + image: UniversalImage, + tintColor: UniversalColor, + side: CGFloat + ) { + self.universalImage = image + self.tintColor = tintColor + self.side = side } - func updateUIView(_ imageView: UIImageView, context: Context) { - imageView.image = self.image - imageView.tintColor = self.tintColor + var body: some View { + self.universalImage.image + .resizable() + .scaledToFit() + .tint(self.tintColor.color) + .frame(width: self.side, height: self.side) } } diff --git a/Sources/ComponentsKit/Components/Button/UKButton.swift b/Sources/ComponentsKit/Components/Button/UKButton.swift index 747897b8..c95a8bd9 100644 --- a/Sources/ComponentsKit/Components/Button/UKButton.swift +++ b/Sources/ComponentsKit/Components/Button/UKButton.swift @@ -255,9 +255,9 @@ extension UKButton { view.isVisible = model.isLoading } static func imageView(_ imageView: UIImageView, model: Model) { - imageView.image = model.image + imageView.image = model.imageWithLegacyFallback?.uiImage imageView.contentMode = .scaleAspectFit - imageView.isHidden = model.isLoading || model.imageSrc.isNil + imageView.isHidden = model.isImageHidden imageView.tintColor = model.foregroundColor.uiColor imageView.isUserInteractionEnabled = true } From 25dcff4c70d528c7ba11ccc2012e0b021bf88ce1 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 6 Feb 2026 16:16:54 +0800 Subject: [PATCH 10/10] leverage `UniversalImage` in avatar and avatar group --- .../PreviewPages/AvatarGroupPreview.swift | 2 +- .../PreviewPages/AvatarPreview.swift | 16 ++++++++---- .../Avatar/Helpers/AvatarImageManager.swift | 8 +++--- .../Avatar/Models/AvatarImageSource.swift | 14 ++++++---- .../Avatar/Models/AvatarPlaceholder.swift | 26 +++++++++---------- .../Components/Avatar/Models/AvatarVM.swift | 10 +++---- .../AvatarGroup/Models/AvatarItemVM.swift | 2 +- 7 files changed, 42 insertions(+), 36 deletions(-) diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift index 948ecbe2..d4014db1 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarGroupPreview.swift @@ -20,7 +20,7 @@ struct AvatarGroupPreview: View { $0.placeholder = .text("IM") }, .init { - $0.placeholder = .sfSymbol("person.circle") + $0.placeholder = .image(.init(systemName: "person.circle")) }, ] } diff --git a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift index 2747d1f6..dc438b6b 100644 --- a/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift +++ b/Examples/DemosApp/DemosApp/ComponentsPreview/PreviewPages/AvatarPreview.swift @@ -4,9 +4,9 @@ import UIKit struct AvatarPreview: View { @State private var model = AvatarVM { - $0.placeholder = .icon("avatar_placeholder") + $0.placeholder = .image(.init("avatar_placeholder")) } - + var body: some View { VStack { PreviewWrapper(title: "UIKit") { @@ -23,13 +23,19 @@ struct AvatarPreview: View { } Picker("Image Source", selection: self.$model.imageSrc) { Text("Remote").tag(AvatarVM.ImageSource.remote(URL(string: "https://i.pravatar.cc/150?img=12")!)) - Text("Local").tag(AvatarVM.ImageSource.local("avatar_image")) + Text("Local").tag( + AvatarVM.ImageSource.local(UniversalImage("avatar_image")) + ) Text("None").tag(Optional.none) } Picker("Placeholder", selection: self.$model.placeholder) { Text("Text").tag(AvatarVM.Placeholder.text("IM")) - Text("SF Symbol").tag(AvatarVM.Placeholder.sfSymbol("person")) - Text("Icon").tag(AvatarVM.Placeholder.icon("avatar_placeholder")) + Text("SF Symbol").tag( + AvatarVM.Placeholder.image(UniversalImage(systemName: "person")) + ) + Text("Asset").tag( + AvatarVM.Placeholder.image(UniversalImage("avatar_placeholder")) + ) } SizePicker(selection: self.$model.size) } diff --git a/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift b/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift index 21067b6c..b71cdc9b 100644 --- a/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift +++ b/Sources/ComponentsKit/Components/Avatar/Helpers/AvatarImageManager.swift @@ -15,8 +15,8 @@ final class AvatarImageManager: ObservableObject { case .remote(let url): self.avatarImage = model.placeholderImage(for: size) self.downloadImage(url: url) - case let .local(name, bundle): - self.avatarImage = UIImage(named: name, in: bundle, compatibleWith: nil) ?? model.placeholderImage(for: size) + case .local(let image): + self.avatarImage = image.uiImage ?? model.placeholderImage(for: size) case .none: self.avatarImage = model.placeholderImage(for: size) } @@ -33,8 +33,8 @@ final class AvatarImageManager: ObservableObject { self.avatarImage = model.placeholderImage(for: size) self.downloadImage(url: url) } - case let .local(name, bundle): - self.avatarImage = UIImage(named: name, in: bundle, compatibleWith: nil) ?? model.placeholderImage(for: size) + case .local(let image): + self.avatarImage = image.uiImage ?? model.placeholderImage(for: size) case .none: self.avatarImage = model.placeholderImage(for: size) } diff --git a/Sources/ComponentsKit/Components/Avatar/Models/AvatarImageSource.swift b/Sources/ComponentsKit/Components/Avatar/Models/AvatarImageSource.swift index fb0b0a4f..81fcc20d 100644 --- a/Sources/ComponentsKit/Components/Avatar/Models/AvatarImageSource.swift +++ b/Sources/ComponentsKit/Components/Avatar/Models/AvatarImageSource.swift @@ -9,11 +9,15 @@ extension AvatarVM { /// - Note: Ensure the URL is valid and accessible to prevent errors during image fetching. case remote(_ url: URL) - /// An image loaded from a local asset. + /// An image referenced locally using a `UniversalImage`. /// - /// - Parameters: - /// - name: The name of the local image asset. - /// - bundle: The bundle containing the image resource. Defaults to `nil`, which uses the main bundle. - case local(_ name: String, _ bundle: Bundle? = nil) + /// - Parameter image: See ``UniversalImage``. + case local(_ image: UniversalImage) + + /// An image loaded from a local asset. + @available(*, deprecated, message: "Use `local(_:)` instead.") + public static func local(_ name: String, _ bundle: Bundle? = nil) -> Self { + return .local(.init(name, bundle: bundle)) + } } } diff --git a/Sources/ComponentsKit/Components/Avatar/Models/AvatarPlaceholder.swift b/Sources/ComponentsKit/Components/Avatar/Models/AvatarPlaceholder.swift index 18f017e2..b1b05975 100644 --- a/Sources/ComponentsKit/Components/Avatar/Models/AvatarPlaceholder.swift +++ b/Sources/ComponentsKit/Components/Avatar/Models/AvatarPlaceholder.swift @@ -13,21 +13,21 @@ extension AvatarVM { /// - Note: Only 3 first letters are displayed. case text(String) - /// A placeholder that displays an SF Symbol. - /// - /// This option allows you to use Apple's system-provided icons as placeholders. + /// A placeholder that displays an image. /// - /// - Parameter name: The name of the SF Symbol to display. - /// - Note: Ensure that the SF Symbol name corresponds to an existing icon in the system's symbol library. - case sfSymbol(_ name: String) + /// - Parameter image: See ``UniversalImage``. + case image(_ image: UniversalImage) + + /// A placeholder that displays an SF Symbol. + @available(*, deprecated, message: "Use `image(_:)` instead.") + public static func sfSymbol(_ name: String) -> Self { + return .image(.init(systemName: name)) + } /// A placeholder that displays a custom icon from an asset catalog. - /// - /// This option allows you to use icons from your app's bundled resources or a specified bundle. - /// - /// - Parameters: - /// - name: The name of the icon asset to use as the placeholder. - /// - bundle: The bundle containing the icon resource. Defaults to `nil`, which uses the main bundle. - case icon(_ name: String, _ bundle: Bundle? = nil) + @available(*, deprecated, message: "Use `image(_:)` instead.") + public static func icon(_ name: String, _ bundle: Bundle? = nil) -> Self { + return .image(.init(name, bundle: bundle)) + } } } diff --git a/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift b/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift index b946e1ed..ee6cedb3 100644 --- a/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift +++ b/Sources/ComponentsKit/Components/Avatar/Models/AvatarVM.swift @@ -14,7 +14,7 @@ public struct AvatarVM: ComponentVM, Hashable { public var imageSrc: ImageSource? /// The placeholder that is displayed if the image is not provided or fails to load. - public var placeholder: Placeholder = .icon("avatar_placeholder", Bundle.module) + public var placeholder: Placeholder = .image(.init("avatar_placeholder", bundle: .module)) /// The predefined size of the avatar. /// @@ -54,12 +54,8 @@ extension AvatarVM { switch self.placeholder { case .text(let value): return self.drawName(value, size: size) - case .icon(let name, let bundle): - let icon = UIImage(named: name, in: bundle, with: nil) - return self.drawIcon(icon, size: size) - case .sfSymbol(let name): - let systemIcon = UIImage(systemName: name) - return self.drawIcon(systemIcon, size: size) + case .image(let image): + return self.drawIcon(image.uiImage, size: size) } } diff --git a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift index 66cdd41f..06f9e197 100644 --- a/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift +++ b/Sources/ComponentsKit/Components/AvatarGroup/Models/AvatarItemVM.swift @@ -6,7 +6,7 @@ public struct AvatarItemVM: ComponentVM { public var imageSrc: AvatarVM.ImageSource? /// The placeholder that is displayed if the image is not provided or fails to load. - public var placeholder: AvatarVM.Placeholder = .icon("avatar_placeholder", Bundle.module) + public var placeholder: AvatarVM.Placeholder = .image(.init("avatar_placeholder", bundle: .module)) /// Initializes a new instance of `AvatarItemVM` with default values. public init() {}