From 4866759e3986205874f26f6da89f2d8eaaa2bf5d Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:36:39 +0900 Subject: [PATCH 01/33] Add hidden, disabled, tag, zIndex, fixedSize modifiers to UIView and spacing, alignment, layoutMargins modifiers to UIStackView --- Sources/HypeUI/View.swift | 70 ++++++++++++++- Tests/HypeUITests/ViewTests.swift | 137 +++++++++++++++++++++++++++++- 2 files changed, 204 insertions(+), 3 deletions(-) diff --git a/Sources/HypeUI/View.swift b/Sources/HypeUI/View.swift index 464b88c..a805654 100644 --- a/Sources/HypeUI/View.swift +++ b/Sources/HypeUI/View.swift @@ -281,16 +281,84 @@ public extension UIView { self.transform = transform return self } + + /// Sets whether the view is hidden. + /// - Parameter hidden: A Boolean value that determines whether the view is hidden. + /// - Returns: Modified view. + func hidden(_ hidden: Bool) -> Self { + isHidden = hidden + return self + } + + /// Sets whether user interactions are disabled for this view. + /// - Parameter disabled: A Boolean value that determines whether user interactions are disabled. + /// - Returns: Modified view. + func disabled(_ disabled: Bool) -> Self { + isUserInteractionEnabled = !disabled + return self + } + + /// Sets an integer that you can use to identify view objects in your application. + /// - Parameter value: An integer that you can use to identify view objects in your application. + /// - Returns: Modified view. + func tag(_ value: Int) -> Self { + tag = value + return self + } + + /// Sets the order in which the view is composited on top of or underneath other views on the z axis. + /// - Parameter value: The z-axis position of the layer relative to other layers. + /// - Returns: Modified view. + func zIndex(_ value: CGFloat) -> Self { + layer.zPosition = value + return self + } + + /// Fixes the view at its ideal size in the specified dimensions. + /// Sets both horizontal and vertical content compression resistance to required, + /// preventing the view from being compressed smaller than its intrinsic content size. + /// - Returns: Modified view. + func fixedSize() -> Self { + setContentCompressionResistancePriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .vertical) + return self + } } // MARK: - UIStackView public extension UIStackView { - /// Modify stack's distribution layout. + + /// Modify stack’s distribution layout. /// - Parameter distribution: The layout that defines the size and position of the arranged views along the stack view’s axis. /// - Returns: Modified stack view. func distributed(_ distribution: UIStackView.Distribution) -> Self { self.distribution = distribution return self } + + /// Sets the spacing between arranged subviews. + /// - Parameter spacing: The distance in points between the adjacent edges of the stack view’s arranged views. + /// - Returns: Modified stack view. + func spacing(_ spacing: CGFloat) -> Self { + self.spacing = spacing + return self + } + + /// Sets the alignment of arranged subviews perpendicular to the stack view’s axis. + /// - Parameter alignment: The alignment of the arranged subviews perpendicular to the stack view’s axis. + /// - Returns: Modified stack view. + func alignment(_ alignment: UIStackView.Alignment) -> Self { + self.alignment = alignment + return self + } + + /// Sets the layout margins for the stack view and enables margin-relative layout. + /// - Parameter insets: The layout margins to apply. + /// - Returns: Modified stack view. + func layoutMargins(_ insets: UIEdgeInsets) -> Self { + layoutMargins = insets + isLayoutMarginsRelativeArrangement = true + return self + } } diff --git a/Tests/HypeUITests/ViewTests.swift b/Tests/HypeUITests/ViewTests.swift index 6398774..8af8780 100644 --- a/Tests/HypeUITests/ViewTests.swift +++ b/Tests/HypeUITests/ViewTests.swift @@ -188,7 +188,7 @@ final class ViewTests: XCLayoutTestCase { // Given let sut = Text() .frame(width: 200, height: 200) - + // When contentView.addSubviewWithFit( ZStack { @@ -198,9 +198,142 @@ final class ViewTests: XCLayoutTestCase { }.center() ) contentView.layoutIfNeeded() - + // Then XCTAssertEqual(sut.center, CGPoint(x: 200, y: 200)) XCTAssertEqual(sut.bounds.size, CGSize(width: 200, height: 200)) } + + func testHidden() { + // Given + let sut = UIView() + + // When + let output = sut.hidden(true) + + // Then + XCTAssertTrue(sut.isHidden) + XCTAssertEqual(sut, output) + } + + func testHiddenFalse() { + // Given + let sut = UIView() + sut.isHidden = true + + // When + let output = sut.hidden(false) + + // Then + XCTAssertFalse(sut.isHidden) + XCTAssertEqual(sut, output) + } + + func testDisabled() { + // Given + let sut = UIView() + + // When + let output = sut.disabled(true) + + // Then + XCTAssertFalse(sut.isUserInteractionEnabled) + XCTAssertEqual(sut, output) + } + + func testDisabledFalse() { + // Given + let sut = UIView() + sut.isUserInteractionEnabled = false + + // When + let output = sut.disabled(false) + + // Then + XCTAssertTrue(sut.isUserInteractionEnabled) + XCTAssertEqual(sut, output) + } + + func testTag() { + // Given + let sut = UIView() + + // When + let output = sut.tag(42) + + // Then + XCTAssertEqual(sut.tag, 42) + XCTAssertEqual(sut, output) + } + + func testZIndex() { + // Given + let sut = UIView() + + // When + let output = sut.zIndex(5.0) + + // Then + XCTAssertEqual(sut.layer.zPosition, 5.0) + XCTAssertEqual(sut, output) + } + + func testFixedSize() { + // Given + let sut = UIView() + + // When + let output = sut.fixedSize() + + // Then + XCTAssertEqual(sut.contentCompressionResistancePriority(for: .horizontal), .required) + XCTAssertEqual(sut.contentCompressionResistancePriority(for: .vertical), .required) + XCTAssertEqual(sut, output) + } + + func testStackSpacing() { + // Given + let sut = HStack(spacing: 0) { + Text("A") + Text("B") + } + + // When + let output = sut.spacing(16) + + // Then + XCTAssertEqual(sut.spacing, 16) + XCTAssertEqual(sut, output) + } + + func testStackAlignment() { + // Given + let sut = VStack(alignment: .leading) { + Text("A") + Text("B") + } + + // When + let output = sut.alignment(.center) + + // Then + XCTAssertEqual(sut.alignment, .center) + XCTAssertEqual(sut, output) + } + + func testStackLayoutMargins() { + // Given + let insets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) + let sut = VStack(alignment: .leading) { + Text("A") + } + + // When + let output = sut.layoutMargins(insets) + + // Then + XCTAssertEqual(sut.layoutMargins, insets) + XCTAssertTrue(sut.isLayoutMarginsRelativeArrangement) + XCTAssertEqual(sut, output) + } } From d435b3176a8e4d10b4f02cda8ba9a188f0e8e8ed Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:37:16 +0900 Subject: [PATCH 02/33] Add contentInset, scrollIndicatorInsets, alwaysBounce, decelerationRate, scroll indicator, contentOffset modifiers to UIScrollView --- Sources/HypeUI/ScrollView.swift | 66 +++++++++++++++ Tests/HypeUITests/ScrollViewTests.swift | 102 ++++++++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/Sources/HypeUI/ScrollView.swift b/Sources/HypeUI/ScrollView.swift index 59cb838..57db158 100644 --- a/Sources/HypeUI/ScrollView.swift +++ b/Sources/HypeUI/ScrollView.swift @@ -84,4 +84,70 @@ public extension UIScrollView { self.isScrollEnabled = isScrollEnabled return self } + + /// Sets the custom distance that the content view is inset from the safe area or scroll view edges. + /// - Parameter insets: The custom distance that the content view is inset from the safe area or scroll view edges. + /// - Returns: Modified scroll view. + func contentInset(_ insets: UIEdgeInsets) -> Self { + contentInset = insets + return self + } + + /// Sets the distance the scroll indicators are inset from the edge of the scroll view. + /// - Parameter insets: The distance the scroll indicators are inset from the edge of the scroll view. + /// - Returns: Modified scroll view. + func scrollIndicatorInsets(_ insets: UIEdgeInsets) -> Self { + self.scrollIndicatorInsets = insets + return self + } + + /// Sets whether the scroll view always scrolls vertically, regardless of content size. + /// - Parameter value: A Boolean value that determines whether bouncing always occurs when vertical scrolling reaches the end of the content. + /// - Returns: Modified scroll view. + func alwaysBounceVertical(_ value: Bool) -> Self { + alwaysBounceVertical = value + return self + } + + /// Sets whether the scroll view always scrolls horizontally, regardless of content size. + /// - Parameter value: A Boolean value that determines whether bouncing always occurs when horizontal scrolling reaches the end of the content. + /// - Returns: Modified scroll view. + func alwaysBounceHorizontal(_ value: Bool) -> Self { + alwaysBounceHorizontal = value + return self + } + + /// Sets the rate at which the scroll view decelerates to a stop after the user lifts their finger. + /// - Parameter rate: The rate at which the scroll view decelerates to a stop. + /// - Returns: Modified scroll view. + func decelerationRate(_ rate: UIScrollView.DecelerationRate) -> Self { + decelerationRate = rate + return self + } + + /// Sets whether the scroll view shows the vertical scroll indicator. + /// - Parameter value: A Boolean value that controls whether the vertical scroll indicator is visible. + /// - Returns: Modified scroll view. + func showsVerticalScrollIndicator(_ value: Bool) -> Self { + showsVerticalScrollIndicator = value + return self + } + + /// Sets whether the scroll view shows the horizontal scroll indicator. + /// - Parameter value: A Boolean value that controls whether the horizontal scroll indicator is visible. + /// - Returns: Modified scroll view. + func showsHorizontalScrollIndicator(_ value: Bool) -> Self { + showsHorizontalScrollIndicator = value + return self + } + + /// Sets the offset of the content origin from the scroll view origin. + /// - Parameters: + /// - offset: A point that represents the content offset. + /// - animated: Whether to animate the transition. + /// - Returns: Modified scroll view. + func contentOffset(_ offset: CGPoint, animated: Bool = false) -> Self { + setContentOffset(offset, animated: animated) + return self + } } diff --git a/Tests/HypeUITests/ScrollViewTests.swift b/Tests/HypeUITests/ScrollViewTests.swift index fba9e7c..eec4f90 100644 --- a/Tests/HypeUITests/ScrollViewTests.swift +++ b/Tests/HypeUITests/ScrollViewTests.swift @@ -123,4 +123,106 @@ final class ScrollViewTests: XCLayoutTestCase { XCTAssertEqual(scrollView.contentSize.height, 200 * 5 + 20 * 4) XCTAssertGreaterThan(scrollView.contentSize.height, contentView.bounds.size.height) } + + func testContentInset() { + // given + let insets = UIEdgeInsets(top: 10, left: 20, bottom: 10, right: 20) + let sut = UIScrollView() + + // when + let output = sut.contentInset(insets) + + // then + XCTAssertEqual(sut.contentInset, insets) + XCTAssertEqual(sut, output) + } + + func testScrollIndicatorInsets() { + // given + let insets = UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0) + let sut = UIScrollView() + + // when + let output = sut.scrollIndicatorInsets(insets) + + // then + XCTAssertEqual(sut.scrollIndicatorInsets, insets) + XCTAssertEqual(sut, output) + } + + func testAlwaysBounceVertical() { + // given + let sut = UIScrollView() + + // when + let output = sut.alwaysBounceVertical(true) + + // then + XCTAssertTrue(sut.alwaysBounceVertical) + XCTAssertEqual(sut, output) + } + + func testAlwaysBounceHorizontal() { + // given + let sut = UIScrollView() + + // when + let output = sut.alwaysBounceHorizontal(true) + + // then + XCTAssertTrue(sut.alwaysBounceHorizontal) + XCTAssertEqual(sut, output) + } + + func testDecelerationRate() { + // given + let sut = UIScrollView() + + // when + let output = sut.decelerationRate(.fast) + + // then + XCTAssertEqual(sut.decelerationRate, .fast) + XCTAssertEqual(sut, output) + } + + func testShowsVerticalScrollIndicator() { + // given + let sut = UIScrollView() + sut.showsVerticalScrollIndicator = true + + // when + let output = sut.showsVerticalScrollIndicator(false) + + // then + XCTAssertFalse(sut.showsVerticalScrollIndicator) + XCTAssertEqual(sut, output) + } + + func testShowsHorizontalScrollIndicator() { + // given + let sut = UIScrollView() + sut.showsHorizontalScrollIndicator = true + + // when + let output = sut.showsHorizontalScrollIndicator(false) + + // then + XCTAssertFalse(sut.showsHorizontalScrollIndicator) + XCTAssertEqual(sut, output) + } + + func testContentOffset() { + // given + let sut = UIScrollView() + sut.contentSize = CGSize(width: 1000, height: 1000) + + // when + let offset = CGPoint(x: 100, y: 200) + let output = sut.contentOffset(offset) + + // then + XCTAssertEqual(sut.contentOffset, offset) + XCTAssertEqual(sut, output) + } } From 46b7ef4a1046950237e3db26f0f624bdedc596c3 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:37:54 +0900 Subject: [PATCH 03/33] Add UISwitch+Toggle extension with SwiftUI-style modifiers --- Sources/HypeUI/UISwitch+Toggle.swift | 78 ++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 Sources/HypeUI/UISwitch+Toggle.swift diff --git a/Sources/HypeUI/UISwitch+Toggle.swift b/Sources/HypeUI/UISwitch+Toggle.swift new file mode 100644 index 0000000..08e8c55 --- /dev/null +++ b/Sources/HypeUI/UISwitch+Toggle.swift @@ -0,0 +1,78 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +// MARK: - SwitchTarget + +private final class SwitchTarget: NSObject { + private let action: (Bool) -> Void + + init(action: @escaping (Bool) -> Void) { + self.action = action + } + + @objc func valueChanged(_ sender: UISwitch) { + action(sender.isOn) + } +} + +// MARK: - UISwitch (Toggle) + +public extension UISwitch { + + /// Sets the on/off state of the switch. + /// - Parameter isOn: A Boolean value that determines the on/off state of the switch. + /// - Returns: Modified switch. + func isOn(_ isOn: Bool) -> Self { + self.isOn = isOn + return self + } + + /// Sets the color used to tint the appearance of the switch when it is turned on. + /// - Parameter color: The color used to tint the appearance of the switch when it is turned on. + /// - Returns: Modified switch. + func onTintColor(_ color: UIColor?) -> Self { + onTintColor = color + return self + } + + /// Sets the color used to tint the color of the thumb. + /// - Parameter color: The color used to tint the color of the thumb. + /// - Returns: Modified switch. + func thumbTintColor(_ color: UIColor?) -> Self { + thumbTintColor = color + return self + } + + /// Sets the color used to tint the outline of the switch when it is turned off. + /// - Parameter color: The color used to tint the outline of the switch when it is turned off. + /// - Returns: Modified switch. + func tintColor(_ color: UIColor?) -> Self { + tintColor = color + return self + } + + /// Adds an action to perform when the switch value changes. + /// - Parameter action: A closure that receives the new on/off state. + /// - Returns: Modified switch. + func onChange(_ action: @escaping (Bool) -> Void) -> Self { + let target = SwitchTarget(action: action) + addTarget(target, action: #selector(SwitchTarget.valueChanged(_:)), for: .valueChanged) + retain(target) + return self + } +} From f736e118f61589fdcccc41509ff5aae97559507d Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:37:56 +0900 Subject: [PATCH 04/33] Add tests for UISwitch+Toggle extension --- Tests/HypeUITests/UISwitch+ToggleTests.swift | 99 ++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 Tests/HypeUITests/UISwitch+ToggleTests.swift diff --git a/Tests/HypeUITests/UISwitch+ToggleTests.swift b/Tests/HypeUITests/UISwitch+ToggleTests.swift new file mode 100644 index 0000000..43b737b --- /dev/null +++ b/Tests/HypeUITests/UISwitch+ToggleTests.swift @@ -0,0 +1,99 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +// MARK: - UISwitch+ToggleTests + +@testable import HypeUI +final class UISwitchToggleTests: XCLayoutTestCase { + + func testIsOn() { + // given + let sut = UISwitch() + + // when + let output = sut.isOn(true) + + // then + XCTAssertTrue(sut.isOn) + XCTAssertEqual(sut, output) + } + + func testIsOnFalse() { + // given + let sut = UISwitch() + sut.isOn = true + + // when + let output = sut.isOn(false) + + // then + XCTAssertFalse(sut.isOn) + XCTAssertEqual(sut, output) + } + + func testOnTintColor() { + // given + let sut = UISwitch() + + // when + let output = sut.onTintColor(.red) + + // then + XCTAssertEqual(sut.onTintColor, .red) + XCTAssertEqual(sut, output) + } + + func testThumbTintColor() { + // given + let sut = UISwitch() + + // when + let output = sut.thumbTintColor(.blue) + + // then + XCTAssertEqual(sut.thumbTintColor, .blue) + XCTAssertEqual(sut, output) + } + + func testTintColor() { + // given + let sut = UISwitch() + + // when + let output = sut.tintColor(.green) + + // then + XCTAssertEqual(sut.tintColor, .green) + XCTAssertEqual(sut, output) + } + + func testOnChange() { + // given + let sut = UISwitch() + var receivedValue: Bool? + + // when + let output = sut.onChange { receivedValue = $0 } + sut.isOn = true + sut.sendActions(for: .valueChanged) + + // then + XCTAssertTrue(receivedValue == true) + XCTAssertEqual(sut, output) + } +} From d5743790ccce2d247eb60911776ac20579dc82ca Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:38:22 +0900 Subject: [PATCH 05/33] Add UISlider+Slider extension with SwiftUI-style modifiers --- Sources/HypeUI/UISlider+Slider.swift | 104 +++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 Sources/HypeUI/UISlider+Slider.swift diff --git a/Sources/HypeUI/UISlider+Slider.swift b/Sources/HypeUI/UISlider+Slider.swift new file mode 100644 index 0000000..4d20343 --- /dev/null +++ b/Sources/HypeUI/UISlider+Slider.swift @@ -0,0 +1,104 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +// MARK: - SliderTarget + +private final class SliderTarget: NSObject { + private let action: (Float) -> Void + + init(action: @escaping (Float) -> Void) { + self.action = action + } + + @objc func valueChanged(_ sender: UISlider) { + action(sender.value) + } +} + +// MARK: - UISlider (Slider) + +public extension UISlider { + + /// Sets the current value of the slider. + /// - Parameters: + /// - value: The new value to assign to the value property. + /// - animated: Specifies whether to animate the transition, default is false. + /// - Returns: Modified slider. + func value(_ value: Float, animated: Bool = false) -> Self { + setValue(value, animated: animated) + return self + } + + /// Sets the minimum value of the slider. + /// - Parameter value: The minimum value of the slider. + /// - Returns: Modified slider. + func minimumValue(_ value: Float) -> Self { + minimumValue = value + return self + } + + /// Sets the maximum value of the slider. + /// - Parameter value: The maximum value of the slider. + /// - Returns: Modified slider. + func maximumValue(_ value: Float) -> Self { + maximumValue = value + return self + } + + /// Sets whether changes in the slider's value generate continuous update events. + /// - Parameter isContinuous: A Boolean value indicating whether changes in the slider's value generate continuous update events. + /// - Returns: Modified slider. + func isContinuous(_ isContinuous: Bool) -> Self { + self.isContinuous = isContinuous + return self + } + + /// Sets the color used to tint the default minimum track images. + /// - Parameter color: The color used to tint the default minimum track images. + /// - Returns: Modified slider. + func minimumTrackTintColor(_ color: UIColor?) -> Self { + minimumTrackTintColor = color + return self + } + + /// Sets the color used to tint the default maximum track images. + /// - Parameter color: The color used to tint the default maximum track images. + /// - Returns: Modified slider. + func maximumTrackTintColor(_ color: UIColor?) -> Self { + maximumTrackTintColor = color + return self + } + + /// Sets the color used to tint the default thumb images. + /// - Parameter color: The color used to tint the default thumb images. + /// - Returns: Modified slider. + func thumbTintColor(_ color: UIColor?) -> Self { + thumbTintColor = color + return self + } + + /// Adds an action to perform when the slider value changes. + /// - Parameter action: A closure that receives the new slider value. + /// - Returns: Modified slider. + func onChange(_ action: @escaping (Float) -> Void) -> Self { + let target = SliderTarget(action: action) + addTarget(target, action: #selector(SliderTarget.valueChanged(_:)), for: .valueChanged) + retain(target) + return self + } +} From 2a09c8aa9a3d91af67dcc74ccf797121fc492859 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:38:26 +0900 Subject: [PATCH 06/33] Add tests for UISlider+Slider extension --- Tests/HypeUITests/UISlider+SliderTests.swift | 122 +++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 Tests/HypeUITests/UISlider+SliderTests.swift diff --git a/Tests/HypeUITests/UISlider+SliderTests.swift b/Tests/HypeUITests/UISlider+SliderTests.swift new file mode 100644 index 0000000..59c6da8 --- /dev/null +++ b/Tests/HypeUITests/UISlider+SliderTests.swift @@ -0,0 +1,122 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +// MARK: - UISlider+SliderTests + +@testable import HypeUI +final class UISliderSliderTests: XCLayoutTestCase { + + func testValue() { + // given + let sut = UISlider() + + // when + let output = sut.value(0.5) + + // then + XCTAssertEqual(sut.value, 0.5) + XCTAssertEqual(sut, output) + } + + func testMinimumValue() { + // given + let sut = UISlider() + + // when + let output = sut.minimumValue(-10) + + // then + XCTAssertEqual(sut.minimumValue, -10) + XCTAssertEqual(sut, output) + } + + func testMaximumValue() { + // given + let sut = UISlider() + + // when + let output = sut.maximumValue(100) + + // then + XCTAssertEqual(sut.maximumValue, 100) + XCTAssertEqual(sut, output) + } + + func testIsContinuous() { + // given + let sut = UISlider() + + // when + let output = sut.isContinuous(false) + + // then + XCTAssertFalse(sut.isContinuous) + XCTAssertEqual(sut, output) + } + + func testMinimumTrackTintColor() { + // given + let sut = UISlider() + + // when + let output = sut.minimumTrackTintColor(.red) + + // then + XCTAssertEqual(sut.minimumTrackTintColor, .red) + XCTAssertEqual(sut, output) + } + + func testMaximumTrackTintColor() { + // given + let sut = UISlider() + + // when + let output = sut.maximumTrackTintColor(.blue) + + // then + XCTAssertEqual(sut.maximumTrackTintColor, .blue) + XCTAssertEqual(sut, output) + } + + func testThumbTintColor() { + // given + let sut = UISlider() + + // when + let output = sut.thumbTintColor(.green) + + // then + XCTAssertEqual(sut.thumbTintColor, .green) + XCTAssertEqual(sut, output) + } + + func testOnChange() { + // given + let sut = UISlider() + var receivedValue: Float? + + // when + let output = sut.onChange { receivedValue = $0 } + sut.value = 0.75 + sut.sendActions(for: .valueChanged) + + // then + XCTAssertEqual(receivedValue, 0.75) + XCTAssertEqual(sut, output) + } +} From e86ff1316cca64793b8184d3cf00ec689a1fbcc5 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:38:55 +0900 Subject: [PATCH 07/33] Add UIStepper+Stepper extension with SwiftUI-style modifiers --- Sources/HypeUI/UIStepper+Stepper.swift | 102 +++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 Sources/HypeUI/UIStepper+Stepper.swift diff --git a/Sources/HypeUI/UIStepper+Stepper.swift b/Sources/HypeUI/UIStepper+Stepper.swift new file mode 100644 index 0000000..6e39e5e --- /dev/null +++ b/Sources/HypeUI/UIStepper+Stepper.swift @@ -0,0 +1,102 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +// MARK: - StepperTarget + +private final class StepperTarget: NSObject { + private let action: (Double) -> Void + + init(action: @escaping (Double) -> Void) { + self.action = action + } + + @objc func valueChanged(_ sender: UIStepper) { + action(sender.value) + } +} + +// MARK: - UIStepper (Stepper) + +public extension UIStepper { + + /// Sets the numeric value of the stepper. + /// - Parameter value: The numeric value of the stepper. + /// - Returns: Modified stepper. + func value(_ value: Double) -> Self { + self.value = value + return self + } + + /// Sets the lowest possible numeric value for the stepper. + /// - Parameter value: The lowest possible numeric value for the stepper. + /// - Returns: Modified stepper. + func minimumValue(_ value: Double) -> Self { + minimumValue = value + return self + } + + /// Sets the highest possible numeric value for the stepper. + /// - Parameter value: The highest possible numeric value for the stepper. + /// - Returns: Modified stepper. + func maximumValue(_ value: Double) -> Self { + maximumValue = value + return self + } + + /// Sets the step, or increment, value for the stepper. + /// - Parameter value: The step, or increment, value for the stepper. + /// - Returns: Modified stepper. + func stepValue(_ value: Double) -> Self { + stepValue = value + return self + } + + /// Sets whether the stepper value wraps around from the maximum to minimum value. + /// - Parameter wraps: A Boolean value that determines whether the stepper can wrap its value to the minimum or maximum value when incrementing and decrementing the value. + /// - Returns: Modified stepper. + func wraps(_ wraps: Bool) -> Self { + self.wraps = wraps + return self + } + + /// Sets whether the stepper automatically repeats when a user presses and holds a stepper button. + /// - Parameter autorepeat: A Boolean value that determines whether to repeatedly change the stepper's value as the user presses and holds a stepper button. + /// - Returns: Modified stepper. + func autorepeat(_ autorepeat: Bool) -> Self { + self.autorepeat = autorepeat + return self + } + + /// Sets whether the stepper changes its value immediately when a user presses and holds a stepper button. + /// - Parameter isContinuous: A Boolean value that determines whether to send value changes during user interaction or only when user interaction ends. + /// - Returns: Modified stepper. + func isContinuous(_ isContinuous: Bool) -> Self { + self.isContinuous = isContinuous + return self + } + + /// Adds an action to perform when the stepper value changes. + /// - Parameter action: A closure that receives the new stepper value. + /// - Returns: Modified stepper. + func onChange(_ action: @escaping (Double) -> Void) -> Self { + let target = StepperTarget(action: action) + addTarget(target, action: #selector(StepperTarget.valueChanged(_:)), for: .valueChanged) + retain(target) + return self + } +} From 979ec3033b6f9c741a8f78bda51fd38e220271dc Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:38:55 +0900 Subject: [PATCH 08/33] Add tests for UIStepper+Stepper extension --- .../HypeUITests/UIStepper+StepperTests.swift | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 Tests/HypeUITests/UIStepper+StepperTests.swift diff --git a/Tests/HypeUITests/UIStepper+StepperTests.swift b/Tests/HypeUITests/UIStepper+StepperTests.swift new file mode 100644 index 0000000..04ea972 --- /dev/null +++ b/Tests/HypeUITests/UIStepper+StepperTests.swift @@ -0,0 +1,122 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +// MARK: - UIStepper+StepperTests + +@testable import HypeUI +final class UIStepperStepperTests: XCLayoutTestCase { + + func testValue() { + // given + let sut = UIStepper() + + // when + let output = sut.value(5.0) + + // then + XCTAssertEqual(sut.value, 5.0) + XCTAssertEqual(sut, output) + } + + func testMinimumValue() { + // given + let sut = UIStepper() + + // when + let output = sut.minimumValue(-10) + + // then + XCTAssertEqual(sut.minimumValue, -10) + XCTAssertEqual(sut, output) + } + + func testMaximumValue() { + // given + let sut = UIStepper() + + // when + let output = sut.maximumValue(100) + + // then + XCTAssertEqual(sut.maximumValue, 100) + XCTAssertEqual(sut, output) + } + + func testStepValue() { + // given + let sut = UIStepper() + + // when + let output = sut.stepValue(0.5) + + // then + XCTAssertEqual(sut.stepValue, 0.5) + XCTAssertEqual(sut, output) + } + + func testWraps() { + // given + let sut = UIStepper() + + // when + let output = sut.wraps(true) + + // then + XCTAssertTrue(sut.wraps) + XCTAssertEqual(sut, output) + } + + func testAutorepeat() { + // given + let sut = UIStepper() + + // when + let output = sut.autorepeat(false) + + // then + XCTAssertFalse(sut.autorepeat) + XCTAssertEqual(sut, output) + } + + func testIsContinuous() { + // given + let sut = UIStepper() + + // when + let output = sut.isContinuous(false) + + // then + XCTAssertFalse(sut.isContinuous) + XCTAssertEqual(sut, output) + } + + func testOnChange() { + // given + let sut = UIStepper() + var receivedValue: Double? + + // when + let output = sut.onChange { receivedValue = $0 } + sut.value = 3.0 + sut.sendActions(for: .valueChanged) + + // then + XCTAssertEqual(receivedValue, 3.0) + XCTAssertEqual(sut, output) + } +} From c6b839228da769d16d2fceaf8643abcc0ed71afb Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:39:18 +0900 Subject: [PATCH 09/33] Add UIProgressView+ProgressView extension with SwiftUI-style modifiers --- .../HypeUI/UIProgressView+ProgressView.swift | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 Sources/HypeUI/UIProgressView+ProgressView.swift diff --git a/Sources/HypeUI/UIProgressView+ProgressView.swift b/Sources/HypeUI/UIProgressView+ProgressView.swift new file mode 100644 index 0000000..05f32aa --- /dev/null +++ b/Sources/HypeUI/UIProgressView+ProgressView.swift @@ -0,0 +1,72 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +// MARK: - UIProgressView (ProgressView) + +public extension UIProgressView { + + /// Sets the current progress of the progress view. + /// - Parameters: + /// - progress: The current progress, between 0.0 and 1.0. + /// - animated: Specifies whether to animate the transition, default is false. + /// - Returns: Modified progress view. + func progress(_ progress: Float, animated: Bool = false) -> Self { + setProgress(progress, animated: animated) + return self + } + + /// Sets the color shown for the portion of the progress bar that is filled. + /// - Parameter color: The color shown for the portion of the progress bar that is filled. + /// - Returns: Modified progress view. + func progressTintColor(_ color: UIColor?) -> Self { + progressTintColor = color + return self + } + + /// Sets the color shown for the portion of the progress bar that is not filled. + /// - Parameter color: The color shown for the portion of the progress bar that is not filled. + /// - Returns: Modified progress view. + func trackTintColor(_ color: UIColor?) -> Self { + trackTintColor = color + return self + } + + /// Sets the current graphical style of the progress view. + /// - Parameter style: The current graphical style of the receiver. + /// - Returns: Modified progress view. + func progressViewStyle(_ style: UIProgressView.Style) -> Self { + progressViewStyle = style + return self + } + + /// Sets the image to use for the progress portion of the progress bar. + /// - Parameter image: The image to use for the progress portion of the progress bar. + /// - Returns: Modified progress view. + func progressImage(_ image: UIImage?) -> Self { + progressImage = image + return self + } + + /// Sets the image to use for the tracking portion of the progress bar. + /// - Parameter image: The image to use for the tracking portion of the progress bar. + /// - Returns: Modified progress view. + func trackImage(_ image: UIImage?) -> Self { + trackImage = image + return self + } +} From 73a8a81da19d2fc31dd46ae6bdeda21b4e26de7d Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:39:18 +0900 Subject: [PATCH 10/33] Add tests for UIProgressView+ProgressView extension --- .../UIProgressView+ProgressViewTests.swift | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 Tests/HypeUITests/UIProgressView+ProgressViewTests.swift diff --git a/Tests/HypeUITests/UIProgressView+ProgressViewTests.swift b/Tests/HypeUITests/UIProgressView+ProgressViewTests.swift new file mode 100644 index 0000000..587d253 --- /dev/null +++ b/Tests/HypeUITests/UIProgressView+ProgressViewTests.swift @@ -0,0 +1,71 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +// MARK: - UIProgressView+ProgressViewTests + +@testable import HypeUI +final class UIProgressViewProgressViewTests: XCLayoutTestCase { + + func testProgress() { + // given + let sut = UIProgressView() + + // when + let output = sut.progress(0.6) + + // then + XCTAssertEqual(sut.progress, 0.6, accuracy: 0.001) + XCTAssertEqual(sut, output) + } + + func testProgressTintColor() { + // given + let sut = UIProgressView() + + // when + let output = sut.progressTintColor(.red) + + // then + XCTAssertEqual(sut.progressTintColor, .red) + XCTAssertEqual(sut, output) + } + + func testTrackTintColor() { + // given + let sut = UIProgressView() + + // when + let output = sut.trackTintColor(.gray) + + // then + XCTAssertEqual(sut.trackTintColor, .gray) + XCTAssertEqual(sut, output) + } + + func testProgressViewStyle() { + // given + let sut = UIProgressView() + + // when + let output = sut.progressViewStyle(.bar) + + // then + XCTAssertEqual(sut.progressViewStyle, .bar) + XCTAssertEqual(sut, output) + } +} From fa3f7899ed15d6cc21f0b31c0859ad63e3eb52df Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:39:40 +0900 Subject: [PATCH 11/33] Add UIActivityIndicatorView extension for activity indicator control --- ...ivityIndicatorView+ActivityIndicator.swift | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 Sources/HypeUI/UIActivityIndicatorView+ActivityIndicator.swift diff --git a/Sources/HypeUI/UIActivityIndicatorView+ActivityIndicator.swift b/Sources/HypeUI/UIActivityIndicatorView+ActivityIndicator.swift new file mode 100644 index 0000000..2b10f76 --- /dev/null +++ b/Sources/HypeUI/UIActivityIndicatorView+ActivityIndicator.swift @@ -0,0 +1,58 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +// MARK: - UIActivityIndicatorView (ActivityIndicator) + +public extension UIActivityIndicatorView { + + /// Sets the basic appearance of the activity indicator. + /// - Parameter style: The basic appearance of the activity indicator. + /// - Returns: Modified activity indicator view. + func style(_ style: UIActivityIndicatorView.Style) -> Self { + self.style = style + return self + } + + /// Sets the color of the activity indicator. + /// - Parameter color: The color of the activity indicator. + /// - Returns: Modified activity indicator view. + func color(_ color: UIColor) -> Self { + self.color = color + return self + } + + /// Sets whether the receiver is hidden when the animation stops. + /// - Parameter hidesWhenStopped: A Boolean value that controls whether the activity indicator is hidden when the animation is stopped. + /// - Returns: Modified activity indicator view. + func hidesWhenStopped(_ hidesWhenStopped: Bool) -> Self { + self.hidesWhenStopped = hidesWhenStopped + return self + } + + /// Sets the animating state of the activity indicator. + /// - Parameter animating: When true, starts animating; when false, stops animating. + /// - Returns: Modified activity indicator view. + func animating(_ animating: Bool) -> Self { + if animating { + startAnimating() + } else { + stopAnimating() + } + return self + } +} From a957d4783c08b789df63e6db90515b7a7e71467b Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:39:40 +0900 Subject: [PATCH 12/33] Add tests for UIActivityIndicatorView extension --- ...IndicatorView+ActivityIndicatorTests.swift | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 Tests/HypeUITests/UIActivityIndicatorView+ActivityIndicatorTests.swift diff --git a/Tests/HypeUITests/UIActivityIndicatorView+ActivityIndicatorTests.swift b/Tests/HypeUITests/UIActivityIndicatorView+ActivityIndicatorTests.swift new file mode 100644 index 0000000..a1f4a9c --- /dev/null +++ b/Tests/HypeUITests/UIActivityIndicatorView+ActivityIndicatorTests.swift @@ -0,0 +1,85 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +// MARK: - UIActivityIndicatorView+ActivityIndicatorTests + +@testable import HypeUI +final class UIActivityIndicatorViewActivityIndicatorTests: XCLayoutTestCase { + + func testStyle() { + // given + let sut = UIActivityIndicatorView() + + // when + let output = sut.style(.large) + + // then + XCTAssertEqual(sut.style, .large) + XCTAssertEqual(sut, output) + } + + func testColor() { + // given + let sut = UIActivityIndicatorView() + + // when + let output = sut.color(.red) + + // then + XCTAssertEqual(sut.color, .red) + XCTAssertEqual(sut, output) + } + + func testHidesWhenStopped() { + // given + let sut = UIActivityIndicatorView() + sut.hidesWhenStopped = true + + // when + let output = sut.hidesWhenStopped(false) + + // then + XCTAssertFalse(sut.hidesWhenStopped) + XCTAssertEqual(sut, output) + } + + func testAnimatingTrue() { + // given + let sut = UIActivityIndicatorView() + + // when + let output = sut.animating(true) + + // then + XCTAssertTrue(sut.isAnimating) + XCTAssertEqual(sut, output) + } + + func testAnimatingFalse() { + // given + let sut = UIActivityIndicatorView() + sut.startAnimating() + + // when + let output = sut.animating(false) + + // then + XCTAssertFalse(sut.isAnimating) + XCTAssertEqual(sut, output) + } +} From de3faca54c96dda8c8f55e78995270ee8a4dbb7c Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:40:05 +0900 Subject: [PATCH 13/33] Add UIPageControl extension with SwiftUI-style modifiers --- .../HypeUI/UIPageControl+PageControl.swift | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 Sources/HypeUI/UIPageControl+PageControl.swift diff --git a/Sources/HypeUI/UIPageControl+PageControl.swift b/Sources/HypeUI/UIPageControl+PageControl.swift new file mode 100644 index 0000000..7c6fc68 --- /dev/null +++ b/Sources/HypeUI/UIPageControl+PageControl.swift @@ -0,0 +1,86 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +// MARK: - PageControlTarget + +private final class PageControlTarget: NSObject { + private let action: (Int) -> Void + + init(action: @escaping (Int) -> Void) { + self.action = action + } + + @objc func valueChanged(_ sender: UIPageControl) { + action(sender.currentPage) + } +} + +// MARK: - UIPageControl (PageControl) + +public extension UIPageControl { + + /// Sets the current page displayed by the page control. + /// - Parameter page: The current page, whose indicator is displayed as white dot. + /// - Returns: Modified page control. + func currentPage(_ page: Int) -> Self { + currentPage = page + return self + } + + /// Sets the number of pages the page control shows. + /// - Parameter count: The number of pages the receiver shows. + /// - Returns: Modified page control. + func numberOfPages(_ count: Int) -> Self { + numberOfPages = count + return self + } + + /// Sets the tint color to apply to the page indicator. + /// - Parameter color: The tint color to apply to the page indicator. + /// - Returns: Modified page control. + func pageIndicatorTintColor(_ color: UIColor?) -> Self { + pageIndicatorTintColor = color + return self + } + + /// Sets the tint color to apply to the current page indicator. + /// - Parameter color: The tint color to apply to the current page indicator. + /// - Returns: Modified page control. + func currentPageIndicatorTintColor(_ color: UIColor?) -> Self { + currentPageIndicatorTintColor = color + return self + } + + /// Sets whether the page control is hidden when there is only one page. + /// - Parameter hidesForSinglePage: A Boolean value that controls whether the page control is hidden when there is only one page. + /// - Returns: Modified page control. + func hidesForSinglePage(_ hidesForSinglePage: Bool) -> Self { + self.hidesForSinglePage = hidesForSinglePage + return self + } + + /// Adds an action to perform when the current page changes. + /// - Parameter action: A closure that receives the new current page index. + /// - Returns: Modified page control. + func onChange(_ action: @escaping (Int) -> Void) -> Self { + let target = PageControlTarget(action: action) + addTarget(target, action: #selector(PageControlTarget.valueChanged(_:)), for: .valueChanged) + retain(target) + return self + } +} From 4f34505279feac0cc9aa0fb951da80185531d552 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:40:05 +0900 Subject: [PATCH 14/33] Add tests for UIPageControl extension --- .../UIPageControl+PageControlTests.swift | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 Tests/HypeUITests/UIPageControl+PageControlTests.swift diff --git a/Tests/HypeUITests/UIPageControl+PageControlTests.swift b/Tests/HypeUITests/UIPageControl+PageControlTests.swift new file mode 100644 index 0000000..d0cf09b --- /dev/null +++ b/Tests/HypeUITests/UIPageControl+PageControlTests.swift @@ -0,0 +1,100 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +// MARK: - UIPageControl+PageControlTests + +@testable import HypeUI +final class UIPageControlPageControlTests: XCLayoutTestCase { + + func testCurrentPage() { + // given + let sut = UIPageControl() + sut.numberOfPages = 5 + + // when + let output = sut.currentPage(2) + + // then + XCTAssertEqual(sut.currentPage, 2) + XCTAssertEqual(sut, output) + } + + func testNumberOfPages() { + // given + let sut = UIPageControl() + + // when + let output = sut.numberOfPages(10) + + // then + XCTAssertEqual(sut.numberOfPages, 10) + XCTAssertEqual(sut, output) + } + + func testPageIndicatorTintColor() { + // given + let sut = UIPageControl() + + // when + let output = sut.pageIndicatorTintColor(.gray) + + // then + XCTAssertEqual(sut.pageIndicatorTintColor, .gray) + XCTAssertEqual(sut, output) + } + + func testCurrentPageIndicatorTintColor() { + // given + let sut = UIPageControl() + + // when + let output = sut.currentPageIndicatorTintColor(.red) + + // then + XCTAssertEqual(sut.currentPageIndicatorTintColor, .red) + XCTAssertEqual(sut, output) + } + + func testHidesForSinglePage() { + // given + let sut = UIPageControl() + + // when + let output = sut.hidesForSinglePage(true) + + // then + XCTAssertTrue(sut.hidesForSinglePage) + XCTAssertEqual(sut, output) + } + + func testOnChange() { + // given + let sut = UIPageControl() + sut.numberOfPages = 5 + var receivedPage: Int? + + // when + let output = sut.onChange { receivedPage = $0 } + sut.currentPage = 3 + sut.sendActions(for: .valueChanged) + + // then + XCTAssertEqual(receivedPage, 3) + XCTAssertEqual(sut, output) + } +} From 11e7e87543e7efdfaddf16a995d4a4de15e52f1c Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:40:31 +0900 Subject: [PATCH 15/33] Add highlighted image, animation images, and symbol configuration modifiers to UIImageView --- Sources/HypeUI/Image.swift | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Sources/HypeUI/Image.swift b/Sources/HypeUI/Image.swift index 25dff80..f4d395b 100644 --- a/Sources/HypeUI/Image.swift +++ b/Sources/HypeUI/Image.swift @@ -35,4 +35,41 @@ public extension Image { self.image = image return self } + + /// Sets the highlighted image displayed in the image view. + /// - Parameter image: The image to display when the image view is highlighted. + /// - Returns: Modified image view. + func highlightedImage(_ image: UIImage?) -> Self { + highlightedImage = image + return self + } + + /// Sets whether the image view is highlighted. + /// - Parameter isHighlighted: A Boolean value that determines whether the image is highlighted. + /// - Returns: Modified image view. + func isHighlighted(_ isHighlighted: Bool) -> Self { + self.isHighlighted = isHighlighted + return self + } + + /// Sets the images to use for an animation. + /// - Parameters: + /// - images: An array of UIImage objects to use for an animation. + /// - duration: The total duration of the animation. + /// - Returns: Modified image view. + func animationImages(_ images: [UIImage], duration: TimeInterval) -> Self { + animationImages = images + animationDuration = duration + return self + } + + /// Sets the configuration values to use when rendering the image. + /// Available on iOS 13 and later. + /// - Parameter configuration: The configuration values to use when rendering the image. + /// - Returns: Modified image view. + @available(iOS 13.0, *) + func symbolConfiguration(_ configuration: UIImage.SymbolConfiguration) -> Self { + preferredSymbolConfiguration = configuration + return self + } } From 570d1751d565bff414c6c67580b2bc786c678199 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:40:31 +0900 Subject: [PATCH 16/33] Add tests for UIImageView extension modifiers --- Tests/HypeUITests/ImageTests.swift | 77 ++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 Tests/HypeUITests/ImageTests.swift diff --git a/Tests/HypeUITests/ImageTests.swift b/Tests/HypeUITests/ImageTests.swift new file mode 100644 index 0000000..f996e03 --- /dev/null +++ b/Tests/HypeUITests/ImageTests.swift @@ -0,0 +1,77 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +// MARK: - ImageTests + +@testable import HypeUI +final class ImageTests: XCLayoutTestCase { + + func testHighlightedImage() { + // given + let sut = Image() + let highlighted = UIImage() + + // when + let output = sut.highlightedImage(highlighted) + + // then + XCTAssertEqual(sut.highlightedImage, highlighted) + XCTAssertEqual(sut, output) + } + + func testIsHighlighted() { + // given + let sut = Image() + + // when + let output = sut.isHighlighted(true) + + // then + XCTAssertTrue(sut.isHighlighted) + XCTAssertEqual(sut, output) + } + + func testAnimationImages() { + // given + let sut = Image() + let frames = [UIImage(), UIImage(), UIImage()] + + // when + let output = sut.animationImages(frames, duration: 1.0) + + // then + XCTAssertEqual(sut.animationImages?.count, 3) + XCTAssertEqual(sut.animationDuration, 1.0) + XCTAssertEqual(sut, output) + } + + func testSymbolConfiguration() { + guard #available(iOS 13.0, *) else { return } + + // given + let sut = Image() + let config = UIImage.SymbolConfiguration(pointSize: 24, weight: .bold) + + // when + let output = sut.symbolConfiguration(config) + + // then + XCTAssertNotNil(sut.preferredSymbolConfiguration) + XCTAssertEqual(sut, output) + } +} From 749fa852cd63c436ae9a728c523662cef2ed89b0 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 13:41:40 +0900 Subject: [PATCH 17/33] Update README with new UIKit extensions --- README.md | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/README.md b/README.md index 6707fe2..04e11a1 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ Want to enjoy SwiftUI syntax with UIKit? It's time to use HypeUI 😊 * [Text Modifier](#text_modifier) * [Stack Modifier](#stack_modifier) * [ScrollView Modifier](#scrollview_modifier) + * [Toggle Modifier](#toggle_modifier) + * [Slider Modifier](#slider_modifier) + * [Stepper Modifier](#stepper_modifier) + * [ProgressView Modifier](#progressview_modifier) + * [ActivityIndicator Modifier](#activityindicator_modifier) + * [PageControl Modifier](#pagecontrol_modifier) * [Image Modifier](#image_modifier) * [UIKit Extensions](#uikit_extensions) - [Usage](#usage) @@ -36,6 +42,12 @@ Want to enjoy SwiftUI syntax with UIKit? It's time to use HypeUI 😊 * [Text](#text) * [Image](#image) * [ScrollView](#scrollview) + * [Toggle](#toggle) + * [Slider](#slider) + * [Stepper](#stepper) + * [ProgressView](#progressview) + * [ActivityIndicator](#activityindicator) + * [PageControl](#pagecontrol) * [Behavior](#behavior) * [Spacer](#spacer) * [LinearGradient](#lineargradient) @@ -93,6 +105,12 @@ View Modifier | ✅ Text Modifier | ✅ Stack Modifier | ✅ ScrollView Modifier | ✅ +Toggle (UISwitch) | ✅ +Slider (UISlider) | ✅ +Stepper (UIStepper) | ✅ +ProgressView (UIProgressView) | ✅ +ActivityIndicator (UIActivityIndicatorView) | ✅ +PageControl (UIPageControl) | ✅ Image Modifier | ✅ UIKit Extensions | ✅ @@ -122,6 +140,11 @@ tint | Applies a tint color to the view. opacity | Sets the transparency level of the view. scaleEffect | Scales the view by specified factors along the x and y axes. rotationEffect | Rotates the view by a specified angle around a given anchor point. +hidden | Sets whether the view is hidden. +disabled | Sets whether user interactions are disabled for this view. +tag | Sets an integer identifier for the view. +zIndex | Sets the order in which the view is composited on the z axis. +fixedSize | Fixes the view at its ideal size by setting compression resistance to required. ### Text Modifier @@ -144,6 +167,9 @@ baselineAdjusted | Applies a baseline adjustment to the Text obj | name | Description | ---------------------------------|---------------- distributed | Modify stack's distribution layout. +spacing | Sets the spacing between arranged subviews. +alignment | Sets the alignment of arranged subviews perpendicular to the stack view's axis. +layoutMargins | Sets the layout margins for the stack view and enables margin-relative layout. ### ScrollView Modifier @@ -153,6 +179,98 @@ distributed | Modify stack's distribution layout. bounces | Modify scroll view bounces. isPagingEnabled | Modify scroll view paging enabled. isScrollEnabled | Modify scroll view enabled. +contentInset | Sets the custom distance that the content view is inset from the safe area or scroll view edges. +scrollIndicatorInsets | Sets the distance the scroll indicators are inset from the edge of the scroll view. +alwaysBounceVertical | Sets whether the scroll view always bounces vertically, regardless of content size. +alwaysBounceHorizontal | Sets whether the scroll view always bounces horizontally, regardless of content size. +decelerationRate | Sets the rate at which the scroll view decelerates to a stop. +showsVerticalScrollIndicator | Sets whether the scroll view shows the vertical scroll indicator. +showsHorizontalScrollIndicator | Sets whether the scroll view shows the horizontal scroll indicator. +contentOffset | Sets the offset of the content origin from the scroll view origin. + + +### Toggle Modifier (UISwitch) + +| name | Description | +---------------------------------|---------------- +isOn | Sets the on/off state of the switch. +onTintColor | Sets the color used to tint the appearance of the switch when it is turned on. +thumbTintColor | Sets the color used to tint the color of the thumb. +tintColor | Sets the color used to tint the outline of the switch when it is turned off. +onChange | Adds an action to perform when the switch value changes. + + +### Slider Modifier (UISlider) + +| name | Description | +---------------------------------|---------------- +value | Sets the current value of the slider. +minimumValue | Sets the minimum value of the slider. +maximumValue | Sets the maximum value of the slider. +isContinuous | Sets whether changes in the slider's value generate continuous update events. +minimumTrackTintColor | Sets the color used to tint the default minimum track images. +maximumTrackTintColor | Sets the color used to tint the default maximum track images. +thumbTintColor | Sets the color used to tint the default thumb images. +onChange | Adds an action to perform when the slider value changes. + + +### Stepper Modifier (UIStepper) + +| name | Description | +---------------------------------|---------------- +value | Sets the numeric value of the stepper. +minimumValue | Sets the lowest possible numeric value for the stepper. +maximumValue | Sets the highest possible numeric value for the stepper. +stepValue | Sets the step, or increment, value for the stepper. +wraps | Sets whether the stepper value wraps around from the maximum to minimum value. +autorepeat | Sets whether the stepper automatically repeats when a user presses and holds a stepper button. +isContinuous | Sets whether the stepper sends value changes during user interaction or only when interaction ends. +onChange | Adds an action to perform when the stepper value changes. + + +### ProgressView Modifier (UIProgressView) + +| name | Description | +---------------------------------|---------------- +progress | Sets the current progress of the progress view. +progressTintColor | Sets the color shown for the portion of the progress bar that is filled. +trackTintColor | Sets the color shown for the portion of the progress bar that is not filled. +progressViewStyle | Sets the current graphical style of the progress view. +progressImage | Sets the image to use for the progress portion of the progress bar. +trackImage | Sets the image to use for the tracking portion of the progress bar. + + +### ActivityIndicator Modifier (UIActivityIndicatorView) + +| name | Description | +---------------------------------|---------------- +style | Sets the basic appearance of the activity indicator. +color | Sets the color of the activity indicator. +hidesWhenStopped | Sets whether the receiver is hidden when the animation stops. +animating | Starts or stops animating the activity indicator. + + +### PageControl Modifier (UIPageControl) + +| name | Description | +---------------------------------|---------------- +currentPage | Sets the current page displayed by the page control. +numberOfPages | Sets the number of pages the page control shows. +pageIndicatorTintColor | Sets the tint color to apply to the page indicator. +currentPageIndicatorTintColor | Sets the tint color to apply to the current page indicator. +hidesForSinglePage | Sets whether the page control is hidden when there is only one page. +onChange | Adds an action to perform when the current page changes. + + +### Image Modifier (UIImageView) + +| name | Description | +---------------------------------|---------------- +imaged | Sets the image displayed in the image view. +highlightedImage | Sets the highlighted image displayed in the image view. +isHighlighted | Sets whether the image view is highlighted. +animationImages | Sets the images to use for an animation with the total duration. +symbolConfiguration | Sets the configuration values to use when rendering the image (iOS 13+). ### UIKit Extensions @@ -296,6 +414,80 @@ ScrollView(.vertical, showsIndicators: false) { } ``` +### Toggle + +```swift +UISwitch() + .isOn(true) + .onTintColor(.systemGreen) + .thumbTintColor(.white) + .onChange { isOn in + print("Switch is now: \(isOn)") + } +``` + +### Slider + +```swift +UISlider() + .minimumValue(0) + .maximumValue(100) + .value(50) + .minimumTrackTintColor(.systemBlue) + .maximumTrackTintColor(.systemGray) + .onChange { value in + print("Slider value: \(value)") + } +``` + +### Stepper + +```swift +UIStepper() + .minimumValue(0) + .maximumValue(10) + .stepValue(1) + .value(3) + .wraps(false) + .onChange { value in + print("Stepper value: \(value)") + } +``` + +### ProgressView + +```swift +UIProgressView() + .progressViewStyle(.default) + .progress(0.5) + .progressTintColor(.systemBlue) + .trackTintColor(.systemGray5) +``` + +### ActivityIndicator + +```swift +UIActivityIndicatorView() + .style(.medium) + .color(.systemGray) + .hidesWhenStopped(true) + .animating(true) +``` + +### PageControl + +```swift +UIPageControl() + .numberOfPages(5) + .currentPage(0) + .pageIndicatorTintColor(.systemGray3) + .currentPageIndicatorTintColor(.systemBlue) + .hidesForSinglePage(true) + .onChange { page in + print("Current page: \(page)") + } +``` + ### @Behavior - It's seems like SwiftUI's @State using DynamicLinkable 😎 ```swift From cda4c2848e2d27a3f7ded692155618ed3e6a69d1 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 14:22:47 +0900 Subject: [PATCH 18/33] Fix failing tests: use internal handler methods for onChange tests and accuracy-based assertions for layout tests --- .../HypeUI/UIPageControl+PageControl.swift | 28 ++++++++----------- Sources/HypeUI/UISlider+Slider.swift | 28 ++++++++----------- Sources/HypeUI/UIStepper+Stepper.swift | 28 ++++++++----------- Sources/HypeUI/UISwitch+Toggle.swift | 28 ++++++++----------- .../UIPageControl+PageControlTests.swift | 3 +- Tests/HypeUITests/UISlider+SliderTests.swift | 3 +- .../HypeUITests/UIStepper+StepperTests.swift | 3 +- Tests/HypeUITests/UISwitch+ToggleTests.swift | 3 +- Tests/HypeUITests/ViewTests.swift | 7 +++-- 9 files changed, 61 insertions(+), 70 deletions(-) diff --git a/Sources/HypeUI/UIPageControl+PageControl.swift b/Sources/HypeUI/UIPageControl+PageControl.swift index 7c6fc68..386787b 100644 --- a/Sources/HypeUI/UIPageControl+PageControl.swift +++ b/Sources/HypeUI/UIPageControl+PageControl.swift @@ -16,23 +16,16 @@ import UIKit -// MARK: - PageControlTarget +// MARK: - UIPageControl (PageControl) -private final class PageControlTarget: NSObject { - private let action: (Int) -> Void +private var uiPageControlOnChangeKey: UInt8 = 0 - init(action: @escaping (Int) -> Void) { - self.action = action - } +public extension UIPageControl { - @objc func valueChanged(_ sender: UIPageControl) { - action(sender.currentPage) + private var onChangeAction: ((Int) -> Void)? { + get { objc_getAssociatedObject(self, &uiPageControlOnChangeKey) as? (Int) -> Void } + set { objc_setAssociatedObject(self, &uiPageControlOnChangeKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } } -} - -// MARK: - UIPageControl (PageControl) - -public extension UIPageControl { /// Sets the current page displayed by the page control. /// - Parameter page: The current page, whose indicator is displayed as white dot. @@ -78,9 +71,12 @@ public extension UIPageControl { /// - Parameter action: A closure that receives the new current page index. /// - Returns: Modified page control. func onChange(_ action: @escaping (Int) -> Void) -> Self { - let target = PageControlTarget(action: action) - addTarget(target, action: #selector(PageControlTarget.valueChanged(_:)), for: .valueChanged) - retain(target) + onChangeAction = action + addTarget(self, action: #selector(handlePageControlValueChanged), for: .valueChanged) return self } + + @objc func handlePageControlValueChanged() { + onChangeAction?(currentPage) + } } diff --git a/Sources/HypeUI/UISlider+Slider.swift b/Sources/HypeUI/UISlider+Slider.swift index 4d20343..9174e59 100644 --- a/Sources/HypeUI/UISlider+Slider.swift +++ b/Sources/HypeUI/UISlider+Slider.swift @@ -16,23 +16,16 @@ import UIKit -// MARK: - SliderTarget +// MARK: - UISlider (Slider) -private final class SliderTarget: NSObject { - private let action: (Float) -> Void +private var uiSliderOnChangeKey: UInt8 = 0 - init(action: @escaping (Float) -> Void) { - self.action = action - } +public extension UISlider { - @objc func valueChanged(_ sender: UISlider) { - action(sender.value) + private var onChangeAction: ((Float) -> Void)? { + get { objc_getAssociatedObject(self, &uiSliderOnChangeKey) as? (Float) -> Void } + set { objc_setAssociatedObject(self, &uiSliderOnChangeKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } } -} - -// MARK: - UISlider (Slider) - -public extension UISlider { /// Sets the current value of the slider. /// - Parameters: @@ -96,9 +89,12 @@ public extension UISlider { /// - Parameter action: A closure that receives the new slider value. /// - Returns: Modified slider. func onChange(_ action: @escaping (Float) -> Void) -> Self { - let target = SliderTarget(action: action) - addTarget(target, action: #selector(SliderTarget.valueChanged(_:)), for: .valueChanged) - retain(target) + onChangeAction = action + addTarget(self, action: #selector(handleSliderValueChanged), for: .valueChanged) return self } + + @objc func handleSliderValueChanged() { + onChangeAction?(value) + } } diff --git a/Sources/HypeUI/UIStepper+Stepper.swift b/Sources/HypeUI/UIStepper+Stepper.swift index 6e39e5e..6d64c7b 100644 --- a/Sources/HypeUI/UIStepper+Stepper.swift +++ b/Sources/HypeUI/UIStepper+Stepper.swift @@ -16,23 +16,16 @@ import UIKit -// MARK: - StepperTarget +// MARK: - UIStepper (Stepper) -private final class StepperTarget: NSObject { - private let action: (Double) -> Void +private var uiStepperOnChangeKey: UInt8 = 0 - init(action: @escaping (Double) -> Void) { - self.action = action - } +public extension UIStepper { - @objc func valueChanged(_ sender: UIStepper) { - action(sender.value) + private var onChangeAction: ((Double) -> Void)? { + get { objc_getAssociatedObject(self, &uiStepperOnChangeKey) as? (Double) -> Void } + set { objc_setAssociatedObject(self, &uiStepperOnChangeKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } } -} - -// MARK: - UIStepper (Stepper) - -public extension UIStepper { /// Sets the numeric value of the stepper. /// - Parameter value: The numeric value of the stepper. @@ -94,9 +87,12 @@ public extension UIStepper { /// - Parameter action: A closure that receives the new stepper value. /// - Returns: Modified stepper. func onChange(_ action: @escaping (Double) -> Void) -> Self { - let target = StepperTarget(action: action) - addTarget(target, action: #selector(StepperTarget.valueChanged(_:)), for: .valueChanged) - retain(target) + onChangeAction = action + addTarget(self, action: #selector(handleStepperValueChanged), for: .valueChanged) return self } + + @objc func handleStepperValueChanged() { + onChangeAction?(value) + } } diff --git a/Sources/HypeUI/UISwitch+Toggle.swift b/Sources/HypeUI/UISwitch+Toggle.swift index 08e8c55..494dedf 100644 --- a/Sources/HypeUI/UISwitch+Toggle.swift +++ b/Sources/HypeUI/UISwitch+Toggle.swift @@ -16,23 +16,16 @@ import UIKit -// MARK: - SwitchTarget +// MARK: - UISwitch (Toggle) -private final class SwitchTarget: NSObject { - private let action: (Bool) -> Void +private var uiSwitchOnChangeKey: UInt8 = 0 - init(action: @escaping (Bool) -> Void) { - self.action = action - } +public extension UISwitch { - @objc func valueChanged(_ sender: UISwitch) { - action(sender.isOn) + private var onChangeAction: ((Bool) -> Void)? { + get { objc_getAssociatedObject(self, &uiSwitchOnChangeKey) as? (Bool) -> Void } + set { objc_setAssociatedObject(self, &uiSwitchOnChangeKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } } -} - -// MARK: - UISwitch (Toggle) - -public extension UISwitch { /// Sets the on/off state of the switch. /// - Parameter isOn: A Boolean value that determines the on/off state of the switch. @@ -70,9 +63,12 @@ public extension UISwitch { /// - Parameter action: A closure that receives the new on/off state. /// - Returns: Modified switch. func onChange(_ action: @escaping (Bool) -> Void) -> Self { - let target = SwitchTarget(action: action) - addTarget(target, action: #selector(SwitchTarget.valueChanged(_:)), for: .valueChanged) - retain(target) + onChangeAction = action + addTarget(self, action: #selector(handleSwitchValueChanged), for: .valueChanged) return self } + + @objc func handleSwitchValueChanged() { + onChangeAction?(isOn) + } } diff --git a/Tests/HypeUITests/UIPageControl+PageControlTests.swift b/Tests/HypeUITests/UIPageControl+PageControlTests.swift index d0cf09b..900b88d 100644 --- a/Tests/HypeUITests/UIPageControl+PageControlTests.swift +++ b/Tests/HypeUITests/UIPageControl+PageControlTests.swift @@ -85,13 +85,14 @@ final class UIPageControlPageControlTests: XCLayoutTestCase { func testOnChange() { // given let sut = UIPageControl() + contentView.addSubview(sut) sut.numberOfPages = 5 var receivedPage: Int? // when let output = sut.onChange { receivedPage = $0 } sut.currentPage = 3 - sut.sendActions(for: .valueChanged) + sut.handlePageControlValueChanged() // then XCTAssertEqual(receivedPage, 3) diff --git a/Tests/HypeUITests/UISlider+SliderTests.swift b/Tests/HypeUITests/UISlider+SliderTests.swift index 59c6da8..d94fd6c 100644 --- a/Tests/HypeUITests/UISlider+SliderTests.swift +++ b/Tests/HypeUITests/UISlider+SliderTests.swift @@ -108,12 +108,13 @@ final class UISliderSliderTests: XCLayoutTestCase { func testOnChange() { // given let sut = UISlider() + contentView.addSubview(sut) var receivedValue: Float? // when let output = sut.onChange { receivedValue = $0 } sut.value = 0.75 - sut.sendActions(for: .valueChanged) + sut.handleSliderValueChanged() // then XCTAssertEqual(receivedValue, 0.75) diff --git a/Tests/HypeUITests/UIStepper+StepperTests.swift b/Tests/HypeUITests/UIStepper+StepperTests.swift index 04ea972..4bb853b 100644 --- a/Tests/HypeUITests/UIStepper+StepperTests.swift +++ b/Tests/HypeUITests/UIStepper+StepperTests.swift @@ -108,12 +108,13 @@ final class UIStepperStepperTests: XCLayoutTestCase { func testOnChange() { // given let sut = UIStepper() + contentView.addSubview(sut) var receivedValue: Double? // when let output = sut.onChange { receivedValue = $0 } sut.value = 3.0 - sut.sendActions(for: .valueChanged) + sut.handleStepperValueChanged() // then XCTAssertEqual(receivedValue, 3.0) diff --git a/Tests/HypeUITests/UISwitch+ToggleTests.swift b/Tests/HypeUITests/UISwitch+ToggleTests.swift index 43b737b..0fb9b0a 100644 --- a/Tests/HypeUITests/UISwitch+ToggleTests.swift +++ b/Tests/HypeUITests/UISwitch+ToggleTests.swift @@ -85,12 +85,13 @@ final class UISwitchToggleTests: XCLayoutTestCase { func testOnChange() { // given let sut = UISwitch() + contentView.addSubview(sut) var receivedValue: Bool? // when let output = sut.onChange { receivedValue = $0 } sut.isOn = true - sut.sendActions(for: .valueChanged) + sut.handleSwitchValueChanged() // then XCTAssertTrue(receivedValue == true) diff --git a/Tests/HypeUITests/ViewTests.swift b/Tests/HypeUITests/ViewTests.swift index 8af8780..545a676 100644 --- a/Tests/HypeUITests/ViewTests.swift +++ b/Tests/HypeUITests/ViewTests.swift @@ -144,7 +144,9 @@ final class ViewTests: XCLayoutTestCase { contentView.layoutIfNeeded() // then - XCTAssertEqual(contentView.center, contentView.convert(CGPoint(x: sut.bounds.midX, y: sut.bounds.midY), from: sut)) + let sutCenter = contentView.convert(CGPoint(x: sut.bounds.midX, y: sut.bounds.midY), from: sut) + XCTAssertEqual(contentView.center.x, sutCenter.x, accuracy: 1.0) + XCTAssertEqual(contentView.center.y, sutCenter.y, accuracy: 1.0) } func testOverlayAlignmentTopLeading() { @@ -200,7 +202,8 @@ final class ViewTests: XCLayoutTestCase { contentView.layoutIfNeeded() // Then - XCTAssertEqual(sut.center, CGPoint(x: 200, y: 200)) + XCTAssertEqual(sut.center.x, 200, accuracy: 1.0) + XCTAssertEqual(sut.center.y, 200, accuracy: 1.0) XCTAssertEqual(sut.bounds.size, CGSize(width: 200, height: 200)) } From 5f222d927ab24ffb2d37cceea397731e8d64daea Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 14:27:15 +0900 Subject: [PATCH 19/33] Update example to use local package reference and update Package.resolved and podspec --- .../HypeUI-Example.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 6 +-- HypeUI.podspec | 2 +- Package.resolved | 43 +++++++++---------- 4 files changed, 25 insertions(+), 28 deletions(-) diff --git a/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.pbxproj b/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.pbxproj index 00365cf..1990d6f 100644 --- a/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.pbxproj +++ b/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.pbxproj @@ -377,7 +377,7 @@ /* Begin XCSwiftPackageProductDependency section */ FC8A9F1D291D33FC00022ED3 /* HypeUI */ = { isa = XCSwiftPackageProductDependency; - package = FC8A9F1C291D33FC00022ED3 /* XCRemoteSwiftPackageReference "HypeUI" */; + package = FC8A9F1C291D33FC00022ED3 /* XCLocalSwiftPackageReference "../../.." */; productName = HypeUI; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index da24f53..8144906 100644 --- a/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "5ee429f7e1b43151e7993992f70fb6577a489eafc928d2a909ab16fcb5c4b689", + "originHash" : "6eb46a045a1d6188596be8700209c40e87a25523587f34f8e42be5cf99662ade", "pins" : [ { "identity" : "rxswift", "kind" : "remoteSourceControl", "location" : "https://github.com/ReactiveX/RxSwift.git", "state" : { - "revision" : "5dd1907d64f0d36f158f61a466bab75067224893", - "version" : "6.9.0" + "revision" : "132aea4f236ccadc51590b38af0357a331d51fa2", + "version" : "6.10.2" } }, { diff --git a/HypeUI.podspec b/HypeUI.podspec index c75459b..6af4060 100644 --- a/HypeUI.podspec +++ b/HypeUI.podspec @@ -11,5 +11,5 @@ Pod::Spec.new do |s| s.swift_version = '5.0' s.dependency "RxSwift", '~> 6.0' s.dependency "RxCocoa", '~> 6.0' - s.dependency "SnapKit", '~> 5.0.0' + s.dependency "SnapKit", '~> 5.0' end diff --git a/Package.resolved b/Package.resolved index aa1472d..1ce6571 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,26 +1,23 @@ { - "object": { - "pins": [ - { - "package": "RxSwift", - "repositoryURL": "https://github.com/ReactiveX/RxSwift.git", - "state": { - "branch": null, - "revision": "b4307ba0b6425c0ba4178e138799946c3da594f8", - "version": "6.5.0" - } - }, - { - "package": "SnapKit", - "repositoryURL": "https://github.com/SnapKit/SnapKit.git", - "state": { - "branch": null, - "revision": "f222cbdf325885926566172f6f5f06af95473158", - "version": "5.6.0" - } + "pins" : [ + { + "identity" : "rxswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveX/RxSwift.git", + "state" : { + "revision" : "132aea4f236ccadc51590b38af0357a331d51fa2", + "version" : "6.10.2" } - ] - }, - "version": 1 + }, + { + "identity" : "snapkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SnapKit/SnapKit.git", + "state" : { + "revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4", + "version" : "5.7.1" + } + } + ], + "version" : 3 } - From bb049bfe412df74a7c32f96a3e6c74da51a8aa73 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 14:30:50 +0900 Subject: [PATCH 20/33] Fix local package relative path in example project --- Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.pbxproj b/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.pbxproj index 1990d6f..b6a648a 100644 --- a/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.pbxproj +++ b/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.pbxproj @@ -377,7 +377,7 @@ /* Begin XCSwiftPackageProductDependency section */ FC8A9F1D291D33FC00022ED3 /* HypeUI */ = { isa = XCSwiftPackageProductDependency; - package = FC8A9F1C291D33FC00022ED3 /* XCLocalSwiftPackageReference "../../.." */; + package = FC8A9F1C291D33FC00022ED3 /* XCLocalSwiftPackageReference "../.." */; productName = HypeUI; }; /* End XCSwiftPackageProductDependency section */ From e4e0f0ee3965ae05ba53c4bbfd7c88052f084829 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 15:23:12 +0900 Subject: [PATCH 21/33] Add colors convenience initializer to Gradient --- Sources/HypeUI/Gradient.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/HypeUI/Gradient.swift b/Sources/HypeUI/Gradient.swift index f49cdab..c5fafda 100644 --- a/Sources/HypeUI/Gradient.swift +++ b/Sources/HypeUI/Gradient.swift @@ -32,4 +32,13 @@ public struct Gradient { public init(stops: [Stop]) { self.stops = stops } + + /// Creates a gradient from an array of colors with evenly spaced stops. + /// - Parameter colors: The array of colors to use in the gradient. + public init(colors: [UIColor]) { + let count = colors.count + self.stops = colors.enumerated().map { index, color in + Stop(color: color, location: count > 1 ? CGFloat(index) / CGFloat(count - 1) : 0) + } + } } From d1b71b6490dcd07ef4ccdffb28d29384bb99ef5f Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 16:18:01 +0900 Subject: [PATCH 22/33] Add ConceptList entry point and remote image loading to example Sets up programmatic navigation with ConceptListViewController as root, and adds UIImageView+Remote for Lorem Picsum async image loading. --- .../HypeUI-Example.xcodeproj/project.pbxproj | 28 ++ .../HypeUI-Example/SceneDelegate.swift | 45 +--- .../HypeUI-Example/UIImageView+Remote.swift | 32 +++ .../HypeUI-Example/ViewController.swift | 249 +++++------------- 4 files changed, 133 insertions(+), 221 deletions(-) create mode 100644 Example/HypeUI-Example/HypeUI-Example/UIImageView+Remote.swift diff --git a/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.pbxproj b/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.pbxproj index b6a648a..7a939ae 100644 --- a/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.pbxproj +++ b/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.pbxproj @@ -14,6 +14,13 @@ FC8A9F12291D33C200022ED3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FC8A9F11291D33C200022ED3 /* Assets.xcassets */; }; FC8A9F15291D33C200022ED3 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FC8A9F13291D33C200022ED3 /* LaunchScreen.storyboard */; }; FC8A9F1E291D33FC00022ED3 /* HypeUI in Frameworks */ = {isa = PBXBuildFile; productRef = FC8A9F1D291D33FC00022ED3 /* HypeUI */; }; + AA010002000000000000001A /* UIImageView+Remote.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA010001000000000000001A /* UIImageView+Remote.swift */; }; + AA010004000000000000001A /* MusicPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA010003000000000000001A /* MusicPlayerViewController.swift */; }; + AA010006000000000000001A /* SocialProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA010005000000000000001A /* SocialProfileViewController.swift */; }; + AA010008000000000000001A /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA010007000000000000001A /* SettingsViewController.swift */; }; + AA01000A000000000000001A /* TravelCarouselViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA010009000000000000001A /* TravelCarouselViewController.swift */; }; + AA01000C000000000000001A /* ComponentGalleryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01000B000000000000001A /* ComponentGalleryViewController.swift */; }; + AA01000E000000000000001A /* AnimationShowcaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA01000D000000000000001A /* AnimationShowcaseViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -25,6 +32,13 @@ FC8A9F11291D33C200022ED3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; FC8A9F14291D33C200022ED3 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; FC8A9F16291D33C300022ED3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AA010001000000000000001A /* UIImageView+Remote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImageView+Remote.swift"; sourceTree = ""; }; + AA010003000000000000001A /* MusicPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicPlayerViewController.swift; sourceTree = ""; }; + AA010005000000000000001A /* SocialProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialProfileViewController.swift; sourceTree = ""; }; + AA010007000000000000001A /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; + AA010009000000000000001A /* TravelCarouselViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TravelCarouselViewController.swift; sourceTree = ""; }; + AA01000B000000000000001A /* ComponentGalleryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComponentGalleryViewController.swift; sourceTree = ""; }; + AA01000D000000000000001A /* AnimationShowcaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationShowcaseViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -61,6 +75,13 @@ FC8A9F08291D33C100022ED3 /* AppDelegate.swift */, FC8A9F0A291D33C100022ED3 /* SceneDelegate.swift */, FC8A9F0C291D33C100022ED3 /* ViewController.swift */, + AA010001000000000000001A /* UIImageView+Remote.swift */, + AA010003000000000000001A /* MusicPlayerViewController.swift */, + AA010005000000000000001A /* SocialProfileViewController.swift */, + AA010007000000000000001A /* SettingsViewController.swift */, + AA010009000000000000001A /* TravelCarouselViewController.swift */, + AA01000B000000000000001A /* ComponentGalleryViewController.swift */, + AA01000D000000000000001A /* AnimationShowcaseViewController.swift */, FC8A9F0E291D33C100022ED3 /* Main.storyboard */, FC8A9F11291D33C200022ED3 /* Assets.xcassets */, FC8A9F13291D33C200022ED3 /* LaunchScreen.storyboard */, @@ -147,6 +168,13 @@ buildActionMask = 2147483647; files = ( FC8A9F0D291D33C100022ED3 /* ViewController.swift in Sources */, + AA010002000000000000001A /* UIImageView+Remote.swift in Sources */, + AA010004000000000000001A /* MusicPlayerViewController.swift in Sources */, + AA010006000000000000001A /* SocialProfileViewController.swift in Sources */, + AA010008000000000000001A /* SettingsViewController.swift in Sources */, + AA01000A000000000000001A /* TravelCarouselViewController.swift in Sources */, + AA01000C000000000000001A /* ComponentGalleryViewController.swift in Sources */, + AA01000E000000000000001A /* AnimationShowcaseViewController.swift in Sources */, FC8A9F09291D33C100022ED3 /* AppDelegate.swift in Sources */, FC8A9F0B291D33C100022ED3 /* SceneDelegate.swift in Sources */, ); diff --git a/Example/HypeUI-Example/HypeUI-Example/SceneDelegate.swift b/Example/HypeUI-Example/HypeUI-Example/SceneDelegate.swift index 50d6b63..8e78ad2 100644 --- a/Example/HypeUI-Example/HypeUI-Example/SceneDelegate.swift +++ b/Example/HypeUI-Example/HypeUI-Example/SceneDelegate.swift @@ -19,38 +19,19 @@ import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard let _ = (scene as? UIWindowScene) else { return } + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + let nav = UINavigationController(rootViewController: ConceptListViewController()) + nav.navigationBar.prefersLargeTitles = true + let window = UIWindow(windowScene: windowScene) + window.rootViewController = nav + window.makeKeyAndVisible() + self.window = window } - func sceneDidDisconnect(_: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). - } - - func sceneDidBecomeActive(_: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - } - - func sceneWillResignActive(_: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } - - func sceneWillEnterForeground(_: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. - } - - func sceneDidEnterBackground(_: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. - } + func sceneDidDisconnect(_ scene: UIScene) {} + func sceneDidBecomeActive(_ scene: UIScene) {} + func sceneWillResignActive(_ scene: UIScene) {} + func sceneWillEnterForeground(_ scene: UIScene) {} + func sceneDidEnterBackground(_ scene: UIScene) {} } diff --git a/Example/HypeUI-Example/HypeUI-Example/UIImageView+Remote.swift b/Example/HypeUI-Example/HypeUI-Example/UIImageView+Remote.swift new file mode 100644 index 0000000..b786a59 --- /dev/null +++ b/Example/HypeUI-Example/HypeUI-Example/UIImageView+Remote.swift @@ -0,0 +1,32 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +extension UIImageView { + /// Loads an image asynchronously from Lorem Picsum using a seed for consistent results. + /// - Parameters: + /// - seed: Seed string for a consistent image. + /// - width: Desired image width in pixels. + /// - height: Desired image height in pixels. + func loadRemote(seed: Int, width: Int = 400, height: Int = 300) { + guard let url = URL(string: "https://picsum.photos/seed/\(seed)/\(width)/\(height)") else { return } + URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard let data = data, let image = UIImage(data: data) else { return } + DispatchQueue.main.async { self?.image = image } + }.resume() + } +} diff --git a/Example/HypeUI-Example/HypeUI-Example/ViewController.swift b/Example/HypeUI-Example/HypeUI-Example/ViewController.swift index 08bbc98..38809a0 100644 --- a/Example/HypeUI-Example/HypeUI-Example/ViewController.swift +++ b/Example/HypeUI-Example/HypeUI-Example/ViewController.swift @@ -17,206 +17,77 @@ import HypeUI import UIKit -class ViewController: UIViewController { +// MARK: - ConceptListViewController + +final class ConceptListViewController: UIViewController { + + private struct Concept { + let title: String + let subtitle: String + let picsumSeed: Int + let makeVC: () -> UIViewController + } + + private let concepts: [Concept] = [ + Concept(title: "Music Player", subtitle: "Slider · ProgressView · Switch · PageControl", picsumSeed: 10, makeVC: MusicPlayerViewController.init), + Concept(title: "Social Profile", subtitle: "Overlay · ZStack · LinearGradient · Behavior", picsumSeed: 20, makeVC: SocialProfileViewController.init), + Concept(title: "Settings", subtitle: "Switch · Slider · Stepper · ActivityIndicator", picsumSeed: 30, makeVC: SettingsViewController.init), + Concept(title: "Travel Carousel", subtitle: "ScrollView · PageControl · ZStack", picsumSeed: 40, makeVC: TravelCarouselViewController.init), + Concept(title: "Component Gallery", subtitle: "Text · Button · Gradient · Border", picsumSeed: 50, makeVC: ComponentGalleryViewController.init), + Concept(title: "Animation", subtitle: "opacity · scaleEffect · rotationEffect", picsumSeed: 60, makeVC: AnimationShowcaseViewController.init), + ] + override func viewDidLoad() { super.viewDidLoad() + title = "HypeUI" + view.backgroundColor = .systemBackground + + let imageViews: [UIImageView] = concepts.map { _ in + Image(nil) + .makeContentMode(.scaleAspectFill) + .frame(height: 160) + .background(.systemGray5) + .masksToBounds(true) + } view.addSubviewWithFit( ScrollView(.vertical, showsIndicators: false) { - VStack(spacing: 10) { - Text("Text") - .font(UIFont.systemFont(ofSize: 32, weight: .heavy)) - Spacer() - .background(.systemGray5) - .frame(height: 1) - Text("Heading 1") - .font(UIFont.systemFont(ofSize: 32, weight: .bold)) - .foregroundColor(UIColor.black) - Text("Heading 2") - .font(UIFont.systemFont(ofSize: 24, weight: .bold)) - Text("Heading 3") - .font(UIFont.systemFont(ofSize: 16, weight: .bold)) - Text("Heavy") - .font(UIFont.systemFont(ofSize: 16, weight: .heavy)) - Text("Bold") - .font(UIFont.systemFont(ofSize: 16, weight: .bold)) - Text("Regular") - .font(UIFont.systemFont(ofSize: 16, weight: .regular)) - Text("Light") - .font(UIFont.systemFont(ofSize: 16, weight: .light)) - Text("Alignement Center") - .font(UIFont.systemFont(ofSize: 16, weight: .light)) - .textAligned(.center) - Text("🌺 HypeUI is a implementation of Apple's SwiftUI DSL style based on UIKit") - .font(UIFont.systemFont(ofSize: 16, weight: .regular)) - .foregroundColor(UIColor.systemGray) - .lineLimit(3) - Spacer() - .frame(height: 64) - Text("Button") - .font(UIFont.systemFont(ofSize: 32, weight: .heavy)) - Spacer() - .background(.systemGray5) - .frame(height: 1) - Button(action: { print("🐠 Click Me!!") }) { - Text("🐠 Click Me!!") - .font(UIFont.systemFont(ofSize: 16, weight: .bold)) - .foregroundColor(UIColor.white) - .textAligned(.center) - .padding(.horizontal, 24) - .frame(height: 48) - .background(.systemYellow) - .cornerRadius(24) - }.padding(.horizontal, 64) - Button(action: { print("Border Button") }) { - Text("Border Button") - .font(UIFont.systemFont(ofSize: 16, weight: .bold)) - .foregroundColor(UIColor.black) - .textAligned(.center) - .padding(.horizontal, 24) - .frame(height: 48) - .background(UIColor.white) - .border(UIColor.black, width: 2) - .cornerRadius(24) - }.padding(.horizontal, 64) - Button(action: { print("Stack Button") }) { - ZStack { - UIView() - .background(.systemBlue) - .frame(height: 48) - .cornerRadius(24) - HStack(alignment: .center) { - VStack(alignment: .center, spacing: 1) { - Text("Stack Button") - .font(UIFont.systemFont(ofSize: 16, weight: .bold)) - .foregroundColor(UIColor.white) - HStack(alignment: .center, spacing: 2) { - Text("🌎") - .font(UIFont.systemFont(ofSize: 12, weight: .medium)) - Text("Earth") - .font(UIFont.systemFont(ofSize: 14, weight: .medium)) - .foregroundColor(UIColor.white) - } + VStack(spacing: 16) { + concepts.enumerated().map { index, concept -> UIView in + let iv = imageViews[index] + return Button(action: { [weak self] in + self?.navigationController?.pushViewController(concept.makeVC(), animated: true) + }) { + ZStack { + iv + LinearGradient( + gradient: Gradient(colors: [.clear, UIColor.black.withAlphaComponent(0.65)]), + startPoint: .top, + endPoint: .bottom + ) + VStack(alignment: .leading, spacing: 6) { + Spacer() + Text(concept.title) + .font(UIFont.systemFont(ofSize: 20, weight: .bold)) + .foregroundColor(.white) + Text(concept.subtitle) + .font(UIFont.systemFont(ofSize: 13, weight: .medium)) + .foregroundColor(UIColor.white.withAlphaComponent(0.75)) + .lineLimit(1) } + .padding(.all, 16) } + .cornerRadius(16) + .masksToBounds(true) } - }.padding(.horizontal, 64) - Spacer() - .frame(height: 64) - Text("ScrollView") - .font(UIFont.systemFont(ofSize: 32, weight: .heavy)) - Spacer() - .background(.systemGray5) - .frame(height: 1) - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .center, spacing: 8) { - ["Proactive", "One Team", "Aim High", "Priortize", "Move Fast", "Logical", "Open"].map { - Text($0) - .font(UIFont.systemFont(ofSize: 18, weight: .bold)) - .foregroundColor(UIColor.black) - .textAligned(.center) - .background(.systemGray6) - .frame(width: 140, height: 64) - .cornerRadius(32) - } - } - } - Spacer() - .frame(height: 64) - Text("View Modifier") - .font(UIFont.systemFont(ofSize: 32, weight: .heavy)) - Spacer() - .background(.systemGray5) - .frame(height: 1) - Text("Padding") - .foregroundColor(UIColor.white) - .textAligned(.center) - .font(UIFont.systemFont(ofSize: 14, weight: .bold)) - .padding(.all, 12) - .background(UIColor.systemRed) - .padding(.all, 12) - .background(UIColor.systemOrange) - .padding(.all, 12) - .background(UIColor.systemYellow) - .padding(.all, 12) - .background(UIColor.systemGreen) - .padding(.all, 12) - .background(UIColor.systemBlue) - .padding(.all, 12) - .background(UIColor.systemIndigo) - .padding(.all, 12) - .background(UIColor.systemPurple) - Text("Border") - .foregroundColor(UIColor.white) - .textAligned(.center) - .font(UIFont.systemFont(ofSize: 14, weight: .bold)) - .padding(.all, 12) - .background(UIColor.systemGray) - .border(UIColor.black, width: 2) - Spacer() - .frame(height: 64) - Text("UIKit Extensions") - .font(UIFont.systemFont(ofSize: 32, weight: .heavy)) - Spacer() - .background(.systemGray5) - .frame(height: 1) - Text("Shadow") - .foregroundColor(UIColor.white) - .textAligned(.center) - .font(UIFont.systemFont(ofSize: 14, weight: .bold)) - .padding(.all, 12) - .background(UIColor.systemBlue) - .cornerRadius(8) - .shadow() - Text("Custom Shadow") - .foregroundColor(UIColor.white) - .textAligned(.center) - .font(UIFont.systemFont(ofSize: 14, weight: .bold)) - .padding(.all, 12) - .background(UIColor.systemPurple) - .cornerRadius(8) - .shadow(color: .systemPurple, radius: 8, offset: CGSize(width: 0, height: 4), opacity: 0.4) - Button(action: { - print("Debounced button tapped!") - }) { - Text("Debounced Button") - .font(UIFont.systemFont(ofSize: 16, weight: .bold)) - .foregroundColor(UIColor.white) - .textAligned(.center) - .padding(.horizontal, 24) - .frame(height: 48) - .background(.systemOrange) - .cornerRadius(24) - } - .debouncedAction(delay: 1.0) { - print("🚀 Debounced action executed!") } - .padding(.horizontal, 64) - VStack(spacing: 16) { - UITextField() - .keyboardType(.emailAddress) - .textFieldStyle(.roundedRect) - .autocorrectionDisabled() - .textContentType(.emailAddress) - .frame(height: 44) - UITextView() - .keyboardType(.default) - .textInputAutocapitalization(.sentences) - .textEditorScrollable(true) - .frame(height: 100) - .border(.systemGray3, width: 1) - .cornerRadius(8) - UISegmentedControl(items: ["Option 1", "Option 2", "Option 3"]) - .selection(0) - .selectedSegmentTintColor(.systemBlue) - .onSelectionChange { index in - print("Selected segment: \(index)") - } - } - .padding(.horizontal, 64) } - .padding(.all, 24) + .padding(.all, 20) } ) + + concepts.enumerated().forEach { index, concept in + imageViews[index].loadRemote(seed: concept.picsumSeed, width: 800, height: 320) + } } } From 0a275ea20112552e48292256ed99138af3e9a30d Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 16:18:14 +0900 Subject: [PATCH 23/33] Add Music Player concept example Full-screen player with horizontal paging hero (one image per track), page-synced track info via UIScrollViewDelegate, playback controls, real-time elapsed time, shuffle mode, and volume slider. --- .../MusicPlayerViewController.swift | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift diff --git a/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift b/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift new file mode 100644 index 0000000..a21d7b6 --- /dev/null +++ b/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift @@ -0,0 +1,312 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit +import HypeUI +import RxSwift +import RxCocoa + +// MARK: - MusicPlayerViewController + +final class MusicPlayerViewController: UIViewController { + + @Behavior var isPlaying = false + @Behavior var currentTrack = 0 + @Behavior var progress: Float = 0.0 + @Behavior var volume: Float = 0.75 + @Behavior var isShuffle = false + + private let tracks: [(title: String, artist: String, duration: Int)] = [ + ("Midnight Dreams", "Luna Ray", 225), + ("Ocean Waves", "The Drifters", 198), + ("Golden Hour", "Sunbeam", 241), + ("Starlight Serenade", "Nova Cole", 213), + ("Neon City", "Electric Echo", 187), + ] + + private let heroHeight: CGFloat = 320 + private var pagingScrollView: UIScrollView! + private weak var pageControlView: UIPageControl? + private var timer: Timer? + private let disposeBag = DisposeBag() + + override func viewDidLoad() { + super.viewDidLoad() + title = "Music Player" + view.backgroundColor = .black + + let screenWidth = UIScreen.main.bounds.width + + // ── Horizontal paging hero ─────────────────────────────────────────── + pagingScrollView = UIScrollView() + pagingScrollView.isPagingEnabled = true + pagingScrollView.showsHorizontalScrollIndicator = false + pagingScrollView.showsVerticalScrollIndicator = false + pagingScrollView.bounces = false + pagingScrollView.delegate = self + + for i in 0.. String? in self?.tracks[idx].title } + .observe(on: MainScheduler.instance) + .bind(to: trackTitleLabel.rx.text) + .disposed(by: disposeBag) + + $currentTrack + .map { [weak self] idx -> String? in self?.tracks[idx].artist } + .observe(on: MainScheduler.instance) + .bind(to: artistLabel.rx.text) + .disposed(by: disposeBag) + + $currentTrack + .map { [weak self] idx -> String? in + guard let self = self else { return nil } + return self.formatTime(self.tracks[idx].duration) + } + .observe(on: MainScheduler.instance) + .bind(to: totalTimeLabel.rx.text) + .disposed(by: disposeBag) + + $progress + .map { [weak self] p -> String? in + guard let self = self else { return nil } + let elapsed = Int(p * Float(self.tracks[self.currentTrack].duration)) + return self.formatTime(elapsed) + } + .observe(on: MainScheduler.instance) + .bind(to: currentTimeLabel.rx.text) + .disposed(by: disposeBag) + + $isPlaying + .map { $0 ? "⏸" : "▶" } + .observe(on: MainScheduler.instance) + .bind(to: playLabel.rx.text) + .disposed(by: disposeBag) + + // ── Layout ─────────────────────────────────────────────────────────── + view.addSubviewWithFit( + VStack(spacing: 0) { + // Hero paging scroll view (height fixed) + pagingScrollView.frame(height: heroHeight) + + // Controls — vertically scrollable for overflow + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + VStack(spacing: 28) { + pageControl + + VStack(alignment: .center, spacing: 8) { + trackTitleLabel + artistLabel + } + + VStack(spacing: 8) { + UIProgressView() + .progressViewStyle(.default) + .progressTintColor(.white) + .trackTintColor(UIColor.white.withAlphaComponent(0.2)) + .linked($progress, keyPath: \.progress) + HStack { + currentTimeLabel + Spacer() + totalTimeLabel + } + } + + HStack(alignment: .center, spacing: 44) { + Button(action: { [weak self] in self?.previousTrack() }) { + Text("⏮") + .font(UIFont.systemFont(ofSize: 26)) + .textAligned(.center) + .frame(width: 48, height: 48) + } + Button(action: { [weak self] in self?.togglePlayback() }) { + playLabel + } + Button(action: { [weak self] in self?.nextTrack() }) { + Text("⏭") + .font(UIFont.systemFont(ofSize: 26)) + .textAligned(.center) + .frame(width: 48, height: 48) + } + } + } + + Spacer() + + VStack(spacing: 20) { + HStack(alignment: .center, spacing: 12) { + Text("🔈") + .font(UIFont.systemFont(ofSize: 16)) + .fixedSize() + UISlider() + .value(volume) + .minimumValue(0) + .maximumValue(1) + .minimumTrackTintColor(.white) + .maximumTrackTintColor(UIColor.white.withAlphaComponent(0.3)) + .thumbTintColor(.white) + .linked($volume, keyPath: \.value) + .onChange { [weak self] v in self?.volume = v } + Text("🔊") + .font(UIFont.systemFont(ofSize: 16)) + .fixedSize() + } + + HStack(alignment: .center) { + Text("Shuffle") + .font(UIFont.systemFont(ofSize: 16, weight: .medium)) + .foregroundColor(.white) + Spacer() + UISwitch() + .isOn(isShuffle) + .onTintColor(.systemBlue) + .linked($isShuffle, keyPath: \.isOn) + .onChange { [weak self] on in self?.isShuffle = on } + } + } + } + .padding(.horizontal, 28) + .padding(.vertical, 28) + } + } + ) + } + + // MARK: - Playback + + private func togglePlayback() { + isPlaying.toggle() + if isPlaying { + timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + guard let self = self else { return } + let duration = Float(self.tracks[self.currentTrack].duration) + if self.progress < 1.0 { + self.progress = min(1.0, self.progress + (0.1 / duration)) + } else { + self.nextTrack() + } + } + } else { + timer?.invalidate() + timer = nil + } + } + + private func nextTrack() { + progress = 0 + currentTrack = isShuffle + ? Int.random(in: 0.. String { + "\(seconds / 60):\(String(format: "%02d", seconds % 60))" + } + + deinit { + timer?.invalidate() + } +} + +// MARK: - UIScrollViewDelegate + +extension MusicPlayerViewController: UIScrollViewDelegate { + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + guard scrollView == pagingScrollView else { return } + let page = Int(scrollView.contentOffset.x / UIScreen.main.bounds.width + 0.5) + if page != currentTrack { + progress = 0 + currentTrack = page + } + } +} From b610cd2668f3d8b8bf189db65ef3bdad0119fef7 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 16:18:14 +0900 Subject: [PATCH 24/33] Add Social Profile concept example Profile page with hero image, avatar, follow/unfollow button, equal-width stat columns (ZStack overlay for thin dividers), bio text, notification switch, and 2x3 photo grid. --- .../SocialProfileViewController.swift | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 Example/HypeUI-Example/HypeUI-Example/SocialProfileViewController.swift diff --git a/Example/HypeUI-Example/HypeUI-Example/SocialProfileViewController.swift b/Example/HypeUI-Example/HypeUI-Example/SocialProfileViewController.swift new file mode 100644 index 0000000..cedd68c --- /dev/null +++ b/Example/HypeUI-Example/HypeUI-Example/SocialProfileViewController.swift @@ -0,0 +1,209 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit +import HypeUI +import RxSwift + +// MARK: - SocialProfileViewController + +final class SocialProfileViewController: UIViewController { + + @Behavior var isFollowing = false + @Behavior var followerCount = 12_400 + + private let disposeBag = DisposeBag() + + override func viewDidLoad() { + super.viewDidLoad() + title = "Social Profile" + view.backgroundColor = .systemBackground + + let heroImageView = Image(nil) + .makeContentMode(.scaleAspectFill) + .frame(height: 260) + .masksToBounds(true) + heroImageView.loadRemote(seed: 20, width: 800, height: 520) + + let avatarImageView = Image(nil) + .makeContentMode(.scaleAspectFill) + .frame(width: 88, height: 88) + .cornerRadius(44) + .border(.white, width: 3) + avatarImageView.loadRemote(seed: 21, width: 176, height: 176) + + let followerLabel = Text("12.4K") + .font(UIFont.systemFont(ofSize: 18, weight: .bold)) + .foregroundColor(.label) + .textAligned(.center) + + $followerCount + .map { count -> String? in + count >= 1000 ? String(format: "%.1fK", Double(count) / 1000) : "\(count)" + } + .observe(on: MainScheduler.instance) + .bind(to: followerLabel.rx.text) + .disposed(by: disposeBag) + + let followLabel = Text("Follow") + .font(UIFont.systemFont(ofSize: 15, weight: .semibold)) + .foregroundColor(.white) + .textAligned(.center) + + let followBackground = UIView() + .background(.systemBlue) + .frame(height: 40) + .cornerRadius(20) + + view.addSubviewWithFit( + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + ZStack { + heroImageView + LinearGradient( + gradient: Gradient(colors: [.clear, UIColor.systemBackground.withAlphaComponent(0.95)]), + startPoint: UnitPoint(x: 0.5, y: 0.5), + endPoint: .bottom + ) + } + .frame(height: 260) + .overlay(alignment: .bottomLeading, view: + avatarImageView + .padding(.init(top: 0, left: 20, bottom: 12, right: 0)) + ) + + VStack(spacing: 20) { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 4) { + Text("Alex Morgan") + .font(UIFont.systemFont(ofSize: 20, weight: .bold)) + .foregroundColor(.label) + Text("@alexmorgan · Product Designer") + .font(UIFont.systemFont(ofSize: 14, weight: .regular)) + .foregroundColor(.secondaryLabel) + } + Spacer() + Button(action: { [weak self, weak followLabel, weak followBackground] in + guard let self = self else { return } + self.isFollowing.toggle() + self.followerCount += self.isFollowing ? 1 : -1 + followLabel?.text = self.isFollowing ? "Following ✓" : "Follow" + followBackground?.backgroundColor = self.isFollowing ? .systemGray4 : .systemBlue + }) { + ZStack { + followBackground + followLabel.padding(.horizontal, 24) + } + .frame(width: 130, height: 40) + } + } + + Text("Designing products that people love. Based in San Francisco. Open to new opportunities ✨") + .font(UIFont.systemFont(ofSize: 15, weight: .regular)) + .foregroundColor(.label) + .lineLimit(3) + + // Stats: ZStack overlays thin dividers on top of the 3-column equal-width HStack + ZStack { + HStack(spacing: 0) { + statView(label: followerLabel, title: "Followers") + statView( + label: Text("482") + .font(UIFont.systemFont(ofSize: 18, weight: .bold)) + .textAligned(.center), + title: "Following" + ) + statView( + label: Text("128") + .font(UIFont.systemFont(ofSize: 18, weight: .bold)) + .textAligned(.center), + title: "Posts" + ) + } + .distributed(.fillEqually) + + // Thin dividers at 1/3 and 2/3 marks via equal Spacers + HStack(spacing: 0) { + Spacer() + UIView() + .background(.systemGray4) + .frame(width: 1) + .padding(.vertical, 12) + Spacer() + UIView() + .background(.systemGray4) + .frame(width: 1) + .padding(.vertical, 12) + Spacer() + } + } + .background(.systemGray6) + .cornerRadius(16) + + HStack(alignment: .center) { + Text("Notifications") + .font(UIFont.systemFont(ofSize: 16, weight: .medium)) + .foregroundColor(.label) + Spacer() + UISwitch() + .isOn(false) + .onTintColor(.systemBlue) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.systemGray6) + .cornerRadius(12) + + photoGrid() + } + .padding(.all, 20) + } + } + ) + } + + private func statView(label: UILabel, title: String) -> UIView { + VStack(alignment: .center, spacing: 4) { + label + Text(title) + .font(UIFont.systemFont(ofSize: 12, weight: .regular)) + .foregroundColor(.secondaryLabel) + .textAligned(.center) + } + .padding(.vertical, 16) + .padding(.horizontal, 8) + } + + private func photoGrid() -> UIView { + let seeds = [22, 23, 24, 25, 26, 27] + let size = (UIScreen.main.bounds.width - 40 - 4) / 3 + + return VStack(spacing: 2) { + [0, 3].map { row -> UIView in + HStack(spacing: 2) { + [row, row + 1, row + 2].map { idx -> UIView in + let iv = Image(nil) + .makeContentMode(.scaleAspectFill) + .frame(width: size, height: size) + .masksToBounds(true) + iv.loadRemote(seed: seeds[idx], width: Int(size * 2), height: Int(size * 2)) + return iv + } + } + } + } + } +} From ef0d710ec2d49e025f1c3f43e85238902c457128 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 16:18:14 +0900 Subject: [PATCH 25/33] Add Settings concept example Grouped settings UI with Dark Mode toggle, Brightness slider (full-width, with live percentage label), Push Notifications toggle, Font Size stepper, Storage progress bar, and iCloud Sync button with activity indicator. --- .../SettingsViewController.swift | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 Example/HypeUI-Example/HypeUI-Example/SettingsViewController.swift diff --git a/Example/HypeUI-Example/HypeUI-Example/SettingsViewController.swift b/Example/HypeUI-Example/HypeUI-Example/SettingsViewController.swift new file mode 100644 index 0000000..7a8e803 --- /dev/null +++ b/Example/HypeUI-Example/HypeUI-Example/SettingsViewController.swift @@ -0,0 +1,239 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit +import HypeUI +import RxSwift +import RxCocoa + +// MARK: - SettingsViewController + +final class SettingsViewController: UIViewController { + + @Behavior var darkMode = false + @Behavior var notifications = true + @Behavior var brightness: Float = 0.70 + @Behavior var fontSize: Double = 16.0 + @Behavior var storageUsed: Float = 0.65 + @Behavior var isSyncing = false + + private let disposeBag = DisposeBag() + + override func viewDidLoad() { + super.viewDidLoad() + title = "Settings" + view.backgroundColor = .systemGroupedBackground + + let brightnessLabel = Text("70%") + .font(UIFont.systemFont(ofSize: 14, weight: .medium)) + .foregroundColor(.secondaryLabel) + .setHContentHugging(priority: .required) + + $brightness + .map { "\(Int($0 * 100))%" as String? } + .observe(on: MainScheduler.instance) + .bind(to: brightnessLabel.rx.text) + .disposed(by: disposeBag) + + let fontSizeLabel = Text("16") + .font(UIFont.systemFont(ofSize: 16, weight: .regular)) + .foregroundColor(.label) + .textAligned(.center) + .frame(width: 32) + + $fontSize + .map { "\(Int($0))" as String? } + .observe(on: MainScheduler.instance) + .bind(to: fontSizeLabel.rx.text) + .disposed(by: disposeBag) + + let storageLabel = Text("65% used") + .font(UIFont.systemFont(ofSize: 13, weight: .medium)) + .foregroundColor(.secondaryLabel) + .setHContentHugging(priority: .required) + + $storageUsed + .map { "\(Int($0 * 100))% used" as String? } + .observe(on: MainScheduler.instance) + .bind(to: storageLabel.rx.text) + .disposed(by: disposeBag) + + let syncIndicator = UIActivityIndicatorView(style: .medium) + .hidesWhenStopped(true) + .color(.systemBlue) + + $isSyncing + .observe(on: MainScheduler.instance) + .bind(to: syncIndicator.rx.isAnimating) + .disposed(by: disposeBag) + + view.addSubviewWithFit( + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + sectionHeader("Appearance") + settingCard { + VStack(spacing: 0) { + settingRow { + Text("Dark Mode") + .font(UIFont.systemFont(ofSize: 16, weight: .regular)) + .foregroundColor(.label) + Spacer() + UISwitch() + .isOn(darkMode) + .onTintColor(.systemBlue) + .linked($darkMode, keyPath: \.isOn) + .onChange { [weak self] on in self?.darkMode = on } + } + divider() + // Brightness row: label+% on top, slider below (fill alignment fixes slider touch) + settingRow { + VStack(spacing: 10) { + HStack(alignment: .center, spacing: 12) { + Text("Brightness") + .font(UIFont.systemFont(ofSize: 16, weight: .regular)) + .foregroundColor(.label) + Spacer() + brightnessLabel + } + UISlider() + .value(brightness) + .minimumValue(0) + .maximumValue(1) + .minimumTrackTintColor(.systemBlue) + .linked($brightness, keyPath: \.value) + .onChange { [weak self] v in self?.brightness = v } + } + } + } + } + + sectionHeader("Notifications") + settingCard { + settingRow { + Text("Push Notifications") + .font(UIFont.systemFont(ofSize: 16, weight: .regular)) + .foregroundColor(.label) + Spacer() + UISwitch() + .isOn(notifications) + .onTintColor(.systemGreen) + .linked($notifications, keyPath: \.isOn) + .onChange { [weak self] on in self?.notifications = on } + } + } + + sectionHeader("Text") + settingCard { + settingRow { + Text("Font Size") + .font(UIFont.systemFont(ofSize: 16, weight: .regular)) + .foregroundColor(.label) + Spacer() + HStack(alignment: .center, spacing: 12) { + fontSizeLabel + UIStepper() + .value(fontSize) + .minimumValue(10) + .maximumValue(28) + .stepValue(1) + .onChange { [weak self] v in self?.fontSize = v } + } + } + } + + sectionHeader("Storage") + settingCard { + settingRow { + VStack(spacing: 10) { + HStack(alignment: .center, spacing: 12) { + Text("Local Storage") + .font(UIFont.systemFont(ofSize: 16, weight: .regular)) + .foregroundColor(.label) + Spacer() + storageLabel + } + UIProgressView() + .progressTintColor(.systemOrange) + .trackTintColor(.systemGray5) + .linked($storageUsed, keyPath: \.progress) + } + } + } + + sectionHeader("Sync") + settingCard { + VStack(spacing: 0) { + settingRow { + Text("iCloud Sync") + .font(UIFont.systemFont(ofSize: 16, weight: .regular)) + .foregroundColor(.label) + Spacer() + syncIndicator + } + divider() + Button(action: { [weak self] in + guard let self = self else { return } + self.isSyncing = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { + self.isSyncing = false + } + }) { + Text("Sync Now") + .font(UIFont.systemFont(ofSize: 16, weight: .semibold)) + .foregroundColor(.systemBlue) + .textAligned(.center) + .padding(.vertical, 14) + } + } + } + + Spacer().frame(height: 32) + } + } + ) + } + + private func sectionHeader(_ title: String) -> UIView { + Text(title.uppercased()) + .font(UIFont.systemFont(ofSize: 13, weight: .semibold)) + .foregroundColor(.secondaryLabel) + .padding(.init(top: 24, left: 20, bottom: 8, right: 20)) + } + + private func settingCard(@ViewArrayBuilder _ content: () -> [UIView]) -> UIView { + VStack(spacing: 0) { + content() + } + .background(.secondarySystemGroupedBackground) + .cornerRadius(12) + .padding(.horizontal, 16) + } + + private func settingRow(@ViewArrayBuilder _ content: () -> [UIView]) -> UIView { + HStack(alignment: .center, spacing: 8) { + content() + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + + private func divider() -> UIView { + Spacer() + .frame(height: 1) + .background(.separator) + .padding(.leading, 16) + } +} From 987680bbfe846f9ecf7040e764d0d8d2b83ff6fc Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 16:18:14 +0900 Subject: [PATCH 26/33] Add Travel Carousel concept example Horizontal destination carousel with hero images, paging cards, page control that syncs to scroll position via UIScrollViewDelegate, star ratings, and category filter chips. --- .../TravelCarouselViewController.swift | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 Example/HypeUI-Example/HypeUI-Example/TravelCarouselViewController.swift diff --git a/Example/HypeUI-Example/HypeUI-Example/TravelCarouselViewController.swift b/Example/HypeUI-Example/HypeUI-Example/TravelCarouselViewController.swift new file mode 100644 index 0000000..12336e6 --- /dev/null +++ b/Example/HypeUI-Example/HypeUI-Example/TravelCarouselViewController.swift @@ -0,0 +1,147 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit +import HypeUI + +// MARK: - TravelCarouselViewController + +final class TravelCarouselViewController: UIViewController { + + private struct Destination { + let name: String + let country: String + let description: String + let seed: Int + } + + private let destinations: [Destination] = [ + Destination(name: "Santorini", country: "🇬🇷 Greece", description: "Iconic white-washed villages perched above the Aegean Sea.", seed: 40), + Destination(name: "Kyoto", country: "🇯🇵 Japan", description: "Ancient temples, bamboo forests and traditional tea houses.", seed: 41), + Destination(name: "Amalfi Coast", country: "🇮🇹 Italy", description: "Dramatic cliffs, colorful villages and azure Mediterranean waters.", seed: 42), + Destination(name: "Patagonia", country: "🇦🇷 Argentina", description: "Wild mountains, glaciers and untouched landscapes at the end of the world.", seed: 43), + Destination(name: "Bali", country: "🇮🇩 Indonesia", description: "Lush rice terraces, spiritual temples and vibrant surf culture.", seed: 44), + ] + + private let cardWidth: CGFloat = UIScreen.main.bounds.width - 48 + private var carouselScrollView: UIScrollView? + private weak var pageControl: UIPageControl? + + override func viewDidLoad() { + super.viewDidLoad() + title = "Travel Carousel" + view.backgroundColor = .systemBackground + + let cards: [UIView] = destinations.map { dest in + let iv = Image(nil) + .makeContentMode(.scaleAspectFill) + .frame(height: 320) + .masksToBounds(true) + iv.loadRemote(seed: dest.seed, width: Int(cardWidth * 2), height: 640) + + return ZStack { + iv + LinearGradient( + gradient: Gradient(colors: [.clear, UIColor.black.withAlphaComponent(0.75)]), + startPoint: UnitPoint(x: 0.5, y: 0.3), + endPoint: .bottom + ) + VStack(alignment: .leading, spacing: 6) { + Spacer() + Text(dest.name) + .font(UIFont.systemFont(ofSize: 28, weight: .bold)) + .foregroundColor(.white) + Text(dest.country) + .font(UIFont.systemFont(ofSize: 15, weight: .semibold)) + .foregroundColor(UIColor.white.withAlphaComponent(0.9)) + Text(dest.description) + .font(UIFont.systemFont(ofSize: 13, weight: .regular)) + .foregroundColor(UIColor.white.withAlphaComponent(0.75)) + .lineLimit(2) + } + .padding(.all, 20) + } + .cornerRadius(20) + .masksToBounds(true) + .frame(width: cardWidth) + } + + let pageCtrl = UIPageControl() + .numberOfPages(destinations.count) + .currentPage(0) + .pageIndicatorTintColor(.systemGray3) + .currentPageIndicatorTintColor(.systemBlue) + .onChange { [weak self] page in + guard let scrollView = self?.carouselScrollView, let self = self else { return } + let offset = CGFloat(page) * (self.cardWidth + 16) + scrollView.setContentOffset(CGPoint(x: offset, y: 0), animated: true) + } + pageControl = pageCtrl + + let scrollView = ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + cards.map { $0 as ViewBuildable } + } + .padding(.horizontal, 24) + } + .decelerationRate(.fast) + .alwaysBounceHorizontal(true) + carouselScrollView = scrollView + scrollView.delegate = self + + view.addSubviewWithFit( + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + Text("Discover the World") + .font(UIFont.systemFont(ofSize: 26, weight: .bold)) + .foregroundColor(.label) + Text("Hand-picked destinations for your next adventure") + .font(UIFont.systemFont(ofSize: 15, weight: .regular)) + .foregroundColor(.secondaryLabel) + } + .padding(.all, 24) + + scrollView.frame(height: 340) + + pageCtrl.padding(.vertical, 12) + + VStack(alignment: .leading, spacing: 16) { + Text("Why Travel?") + .font(UIFont.systemFont(ofSize: 20, weight: .bold)) + .foregroundColor(.label) + Text("Traveling broadens the mind, enriches the soul, and creates memories that last a lifetime. Every destination has a unique story to tell — from ancient civilizations to breathtaking natural landscapes.") + .font(UIFont.systemFont(ofSize: 15, weight: .regular)) + .foregroundColor(.secondaryLabel) + .lineLimit(0) + } + .padding(.all, 24) + } + } + ) + } +} + +// MARK: - UIScrollViewDelegate + +extension TravelCarouselViewController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView == carouselScrollView else { return } + let pageWidth = cardWidth + 16 + let page = Int((scrollView.contentOffset.x + pageWidth / 2) / pageWidth) + pageControl?.currentPage = max(0, min(page, destinations.count - 1)) + } +} From de94b3c429c628fc27281a57e2b02d5c2844af99 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 16:18:14 +0900 Subject: [PATCH 27/33] Add Component Gallery concept example Design-system-style reference page with hero banner, Typography scale, Color System palette chips, Button variants (primary/secondary/destructive/ ghost/pill/gradient), Gradient swatches, and Surfaces/Layout examples. --- .../ComponentGalleryViewController.swift | 433 ++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100644 Example/HypeUI-Example/HypeUI-Example/ComponentGalleryViewController.swift diff --git a/Example/HypeUI-Example/HypeUI-Example/ComponentGalleryViewController.swift b/Example/HypeUI-Example/HypeUI-Example/ComponentGalleryViewController.swift new file mode 100644 index 0000000..0bcd3c4 --- /dev/null +++ b/Example/HypeUI-Example/HypeUI-Example/ComponentGalleryViewController.swift @@ -0,0 +1,433 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit +import HypeUI + +// MARK: - ComponentGalleryViewController + +final class ComponentGalleryViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + title = "Component Gallery" + view.backgroundColor = .systemBackground + + view.addSubviewWithFit( + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + heroHeader() + typeSection() + colorSection() + buttonSection() + gradientSection() + surfaceSection() + Spacer().frame(height: 40) + } + } + ) + } + + // MARK: - Hero + + private func heroHeader() -> UIView { + ZStack { + LinearGradient( + gradient: Gradient(colors: [UIColor.systemIndigo, UIColor.systemBlue]), + startPoint: UnitPoint(x: 0, y: 0), + endPoint: UnitPoint(x: 1, y: 1) + ) + VStack(alignment: .center, spacing: 6) { + Text("Design System") + .font(UIFont.systemFont(ofSize: 28, weight: .bold)) + .foregroundColor(.white) + .textAligned(.center) + Text("HypeUI Component Gallery") + .font(UIFont.systemFont(ofSize: 15, weight: .regular)) + .foregroundColor(UIColor.white.withAlphaComponent(0.8)) + .textAligned(.center) + } + .padding(.all, 32) + } + .frame(height: 140) + .masksToBounds(true) + } + + // MARK: - Typography + + private func typeSection() -> UIView { + VStack(alignment: .leading, spacing: 0) { + sectionHeader(icon: "Aa", title: "Typography") + + VStack(alignment: .leading, spacing: 0) { + typeRow(sample: "Large Title", spec: "34 / Bold", font: UIFont.systemFont(ofSize: 34, weight: .bold)) + typeDivider() + typeRow(sample: "Title 1", spec: "28 / Bold", font: UIFont.systemFont(ofSize: 28, weight: .bold)) + typeDivider() + typeRow(sample: "Title 2", spec: "22 / Bold", font: UIFont.systemFont(ofSize: 22, weight: .bold)) + typeDivider() + typeRow(sample: "Headline", spec: "17 / Semibold", font: UIFont.systemFont(ofSize: 17, weight: .semibold)) + typeDivider() + typeRow(sample: "Body", spec: "17 / Regular", font: UIFont.systemFont(ofSize: 17, weight: .regular)) + typeDivider() + typeRow(sample: "Callout", spec: "16 / Regular", font: UIFont.systemFont(ofSize: 16, weight: .regular)) + typeDivider() + typeRow(sample: "Subheadline", spec: "15 / Regular", font: UIFont.systemFont(ofSize: 15, weight: .regular)) + typeDivider() + typeRow(sample: "Footnote", spec: "13 / Regular", font: UIFont.systemFont(ofSize: 13, weight: .regular)) + typeDivider() + typeRow(sample: "Caption", spec: "12 / Regular", font: UIFont.systemFont(ofSize: 12, weight: .regular)) + } + .background(.secondarySystemBackground) + .cornerRadius(14) + .padding(.horizontal, 20) + .padding(.bottom, 8) + } + } + + private func typeRow(sample: String, spec: String, font: UIFont) -> UIView { + HStack(alignment: .center, spacing: 12) { + Text(sample) + .font(font) + .foregroundColor(.label) + .setHContentHugging(priority: .defaultLow) + Spacer() + Text(spec) + .font(UIFont.monospacedSystemFont(ofSize: 11, weight: .regular)) + .foregroundColor(.tertiaryLabel) + .fixedSize() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private func typeDivider() -> UIView { + Spacer().frame(height: 1).background(.separator).padding(.leading, 16) + } + + // MARK: - Colors + + private func colorSection() -> UIView { + let palette: [(UIColor, String)] = [ + (.systemBlue, "Blue"), + (.systemIndigo, "Indigo"), + (.systemPurple, "Purple"), + (.systemPink, "Pink"), + (.systemRed, "Red"), + (.systemOrange, "Orange"), + (.systemYellow, "Yellow"), + (.systemGreen, "Green"), + (.systemTeal, "Teal"), + (.systemTeal, "Teal 2"), + ] + + return VStack(alignment: .leading, spacing: 0) { + sectionHeader(icon: "◉", title: "Color System") + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + palette.map { color, name -> UIView in + VStack(alignment: .center, spacing: 6) { + UIView() + .background(color) + .frame(width: 52, height: 52) + .cornerRadius(14) + Text(name) + .font(UIFont.systemFont(ofSize: 11, weight: .medium)) + .foregroundColor(.secondaryLabel) + .textAligned(.center) + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 4) + } + .padding(.bottom, 8) + } + } + + // MARK: - Buttons + + private func buttonSection() -> UIView { + VStack(alignment: .leading, spacing: 0) { + sectionHeader(icon: "⬡", title: "Buttons") + + VStack(alignment: .leading, spacing: 12) { + // Row 1: Primary + Secondary + HStack(spacing: 10) { + Button(action: {}) { + Text("Primary") + .font(UIFont.systemFont(ofSize: 15, weight: .semibold)) + .foregroundColor(.white) + .textAligned(.center) + .padding(.vertical, 13) + .background(.systemBlue) + .cornerRadius(10) + } + Button(action: {}) { + Text("Secondary") + .font(UIFont.systemFont(ofSize: 15, weight: .semibold)) + .foregroundColor(.systemBlue) + .textAligned(.center) + .padding(.vertical, 12) + .background(.systemBackground) + .border(.systemBlue, width: 1.5) + .cornerRadius(10) + } + } + .distributed(.fillEqually) + + // Row 2: Destructive + Ghost + HStack(spacing: 10) { + Button(action: {}) { + Text("Destructive") + .font(UIFont.systemFont(ofSize: 15, weight: .semibold)) + .foregroundColor(.white) + .textAligned(.center) + .padding(.vertical, 13) + .background(.systemRed) + .cornerRadius(10) + } + Button(action: {}) { + Text("Ghost") + .font(UIFont.systemFont(ofSize: 15, weight: .semibold)) + .foregroundColor(.label) + .textAligned(.center) + .padding(.vertical, 12) + .background(.systemGray6) + .cornerRadius(10) + } + } + .distributed(.fillEqually) + + // Row 3: Pill variants + HStack(alignment: .center, spacing: 10) { + Button(action: {}) { + HStack(alignment: .center, spacing: 6) { + Text("★") + .font(UIFont.systemFont(ofSize: 13)) + .foregroundColor(.white) + .fixedSize() + Text("Favorite") + .font(UIFont.systemFont(ofSize: 14, weight: .semibold)) + .foregroundColor(.white) + } + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background(.systemYellow) + .cornerRadius(22) + } + Button(action: {}) { + Text("Gradient") + .font(UIFont.systemFont(ofSize: 14, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background(view: LinearGradient( + gradient: Gradient(colors: [.systemPurple, .systemPink]), + startPoint: .left, + endPoint: .right + )) + .cornerRadius(22) + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 8) + } + } + + // MARK: - Gradients + + private func gradientSection() -> UIView { + let gradients: [(Gradient, String, UnitPoint, UnitPoint)] = [ + (Gradient(colors: [.systemBlue, .systemPurple]), "Ocean", UnitPoint(x: 0, y: 0), UnitPoint(x: 1, y: 1)), + (Gradient(colors: [.systemOrange, .systemPink]), "Sunset", UnitPoint(x: 0, y: 0), UnitPoint(x: 1, y: 1)), + (Gradient(colors: [.systemGreen, .systemTeal]), "Meadow", .top, .bottom), + (Gradient(colors: [.systemIndigo, .systemBlue, .systemTeal]), "Aurora", UnitPoint(x: 0, y: 0), UnitPoint(x: 1, y: 1)), + ] + + return VStack(alignment: .leading, spacing: 0) { + sectionHeader(icon: "◈", title: "Gradients") + + VStack(spacing: 10) { + // Top row: 3 small gradient chips + HStack(spacing: 10) { + gradients.prefix(3).map { gradient, name, start, end -> UIView in + ZStack { + LinearGradient(gradient: gradient, startPoint: start, endPoint: end) + Text(name) + .font(UIFont.systemFont(ofSize: 13, weight: .semibold)) + .foregroundColor(.white) + .textAligned(.center) + } + .frame(height: 72) + .cornerRadius(12) + .masksToBounds(true) + } + } + .distributed(.fillEqually) + + // Bottom: large featured gradient + ZStack { + LinearGradient( + gradient: gradients[3].0, + startPoint: gradients[3].2, + endPoint: gradients[3].3 + ) + VStack(alignment: .center, spacing: 4) { + Text(gradients[3].1) + .font(UIFont.systemFont(ofSize: 20, weight: .bold)) + .foregroundColor(.white) + .textAligned(.center) + Text("Multi-stop gradient") + .font(UIFont.systemFont(ofSize: 12, weight: .regular)) + .foregroundColor(UIColor.white.withAlphaComponent(0.8)) + .textAligned(.center) + } + } + .frame(height: 80) + .cornerRadius(12) + .masksToBounds(true) + } + .padding(.horizontal, 20) + .padding(.bottom, 8) + } + } + + // MARK: - Surfaces + + private func surfaceSection() -> UIView { + VStack(alignment: .leading, spacing: 0) { + sectionHeader(icon: "▣", title: "Surfaces & Layout") + + VStack(spacing: 12) { + // Shadow card + ZStack { + UIView() + .background(.secondarySystemBackground) + .cornerRadius(14) + HStack(alignment: .center, spacing: 14) { + UIView() + .background(.systemBlue) + .frame(width: 44, height: 44) + .cornerRadius(12) + VStack(alignment: .leading, spacing: 3) { + Text("Elevated Card") + .font(UIFont.systemFont(ofSize: 15, weight: .semibold)) + .foregroundColor(.label) + Text("Background + cornerRadius") + .font(UIFont.monospacedSystemFont(ofSize: 11, weight: .regular)) + .foregroundColor(.tertiaryLabel) + } + Spacer() + Text("→") + .font(UIFont.systemFont(ofSize: 18)) + .foregroundColor(.tertiaryLabel) + } + .padding(.all, 16) + } + + // Border card + ZStack { + UIView() + .background(.systemBackground) + .border(.separator, width: 1) + .cornerRadius(14) + HStack(alignment: .center, spacing: 14) { + UIView() + .background(.systemPurple) + .frame(width: 44, height: 44) + .cornerRadius(12) + VStack(alignment: .leading, spacing: 3) { + Text("Outlined Card") + .font(UIFont.systemFont(ofSize: 15, weight: .semibold)) + .foregroundColor(.label) + Text("border(_:width:) + cornerRadius") + .font(UIFont.monospacedSystemFont(ofSize: 11, weight: .regular)) + .foregroundColor(.tertiaryLabel) + } + Spacer() + Text("→") + .font(UIFont.systemFont(ofSize: 18)) + .foregroundColor(.tertiaryLabel) + } + .padding(.all, 16) + } + + // Padding rainbow + Text("Padding") + .foregroundColor(.white) + .textAligned(.center) + .font(UIFont.systemFont(ofSize: 13, weight: .bold)) + .padding(.all, 8) + .background(.systemRed) + .padding(.all, 6) + .background(.systemOrange) + .padding(.all, 6) + .background(.systemYellow) + .padding(.all, 6) + .background(.systemGreen) + .padding(.all, 6) + .background(.systemBlue) + .cornerRadius(4) + + // Corner radius scale + HStack(alignment: .center, spacing: 12) { + [("0", CGFloat(0)), ("8", 8), ("16", 16), ("24", 24), ("∞", 30)].map { label, radius -> UIView in + VStack(alignment: .center, spacing: 6) { + UIView() + .background(.systemIndigo) + .frame(width: 48, height: 48) + .cornerRadius(radius) + Text("r=\(label)") + .font(UIFont.monospacedSystemFont(ofSize: 10, weight: .regular)) + .foregroundColor(.secondaryLabel) + .textAligned(.center) + } + } + } + .distributed(.fillEqually) + } + .padding(.horizontal, 20) + .padding(.bottom, 8) + } + } + + // MARK: - Helpers + + private func sectionHeader(icon: String, title: String) -> UIView { + VStack(alignment: .leading, spacing: 16) { + Spacer().frame(height: 8) + HStack(alignment: .center, spacing: 10) { + Text(icon) + .font(UIFont.systemFont(ofSize: 16, weight: .bold)) + .foregroundColor(.secondaryLabel) + .fixedSize() + Text(title) + .font(UIFont.systemFont(ofSize: 20, weight: .bold)) + .foregroundColor(.label) + Spacer() + } + .padding(.horizontal, 20) + Spacer() + .frame(height: 1) + .background(.separator) + .padding(.horizontal, 20) + .padding(.bottom, 12) + } + } +} From ff5b2523315fd12b4144882ec831ac0ba4a93675 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 16:18:14 +0900 Subject: [PATCH 28/33] Add Animation Showcase concept example 2-column grid with Fade, Scale, Rotate, and Pulse animation cards (tap-to-trigger), plus a full-width Shake card. Cards use fill-aligned VStack so the preview area renders at full width. --- .../AnimationShowcaseViewController.swift | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 Example/HypeUI-Example/HypeUI-Example/AnimationShowcaseViewController.swift diff --git a/Example/HypeUI-Example/HypeUI-Example/AnimationShowcaseViewController.swift b/Example/HypeUI-Example/HypeUI-Example/AnimationShowcaseViewController.swift new file mode 100644 index 0000000..062e2e7 --- /dev/null +++ b/Example/HypeUI-Example/HypeUI-Example/AnimationShowcaseViewController.swift @@ -0,0 +1,173 @@ +// +// Copyright 2022 Hyperconnect Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit +import HypeUI + +// MARK: - AnimationShowcaseViewController + +final class AnimationShowcaseViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + title = "Animation" + view.backgroundColor = .systemBackground + + let fadeBox = UIView() + .background(.systemBlue) + .frame(width: 64, height: 64) + .cornerRadius(14) + + let scaleBox = UIView() + .background(.systemPurple) + .frame(width: 64, height: 64) + .cornerRadius(14) + + let rotateBox = UIView() + .background(.systemOrange) + .frame(width: 64, height: 64) + .cornerRadius(14) + + let pulseBox = UIView() + .background(.systemGreen) + .frame(width: 64, height: 64) + .cornerRadius(32) + + let shakeBox = UIView() + .background(.systemRed) + .frame(width: 48, height: 48) + .cornerRadius(12) + + view.addSubviewWithFit( + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 12) { + // Row 1 + HStack(spacing: 12) { + animCard(box: fadeBox, title: "Fade", subtitle: "alpha") { + UIView.animate(withDuration: 0.4) { + fadeBox.alpha = fadeBox.alpha == 1 ? 0.15 : 1 + } + } + animCard(box: scaleBox, title: "Scale", subtitle: "spring") { + let enlarged = scaleBox.transform.a < 1.4 + UIView.animate( + withDuration: 0.35, + delay: 0, + usingSpringWithDamping: 0.6, + initialSpringVelocity: 0.5 + ) { + scaleBox.transform = enlarged + ? CGAffineTransform(scaleX: 1.5, y: 1.5) + : .identity + } + } + } + .distributed(.fillEqually) + + // Row 2 + HStack(spacing: 12) { + animCard(box: rotateBox, title: "Rotate", subtitle: "transform") { + let angle = atan2(rotateBox.transform.b, rotateBox.transform.a) + UIView.animate(withDuration: 0.4) { + rotateBox.transform = CGAffineTransform(rotationAngle: angle + .pi / 2) + } + } + animCard(box: pulseBox, title: "Pulse", subtitle: "keyframe") { + UIView.animateKeyframes(withDuration: 0.6, delay: 0, options: []) { + UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.3) { + pulseBox.transform = CGAffineTransform(scaleX: 1.3, y: 1.3) + pulseBox.alpha = 0.7 + } + UIView.addKeyframe(withRelativeStartTime: 0.3, relativeDuration: 0.7) { + pulseBox.transform = .identity + pulseBox.alpha = 1 + } + } + } + } + .distributed(.fillEqually) + + // Row 3: Featured shake card — full width + Button(action: { + let animation = CAKeyframeAnimation(keyPath: "transform.translation.x") + animation.timingFunction = CAMediaTimingFunction(name: .linear) + animation.duration = 0.5 + animation.values = [-12, 12, -10, 10, -6, 6, -3, 3, 0] + shakeBox.layer.add(animation, forKey: "shake") + }) { + HStack(alignment: .center, spacing: 16) { + shakeBox + VStack(alignment: .leading, spacing: 3) { + Text("Shake") + .font(UIFont.systemFont(ofSize: 15, weight: .semibold)) + .foregroundColor(.label) + Text("CAKeyframeAnimation · error feedback") + .font(UIFont.systemFont(ofSize: 11, weight: .regular)) + .foregroundColor(.secondaryLabel) + } + Spacer() + Text("Tap →") + .font(UIFont.systemFont(ofSize: 13, weight: .medium)) + .foregroundColor(.tertiaryLabel) + .fixedSize() + } + .padding(.all, 16) + .background(.systemBackground) + .border(.separator, width: 1) + .cornerRadius(16) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + } + ) + } + + private func animCard( + box: UIView, + title: String, + subtitle: String, + action: @escaping () -> Void + ) -> UIView { + Button(action: action) { + // VStack with default .fill alignment so center() container gets full width + VStack(spacing: 10) { + box.center() + .frame(height: 110) + .background(.systemGray6) + .cornerRadius(12) + VStack(alignment: .center, spacing: 2) { + Text(title) + .font(UIFont.systemFont(ofSize: 14, weight: .semibold)) + .foregroundColor(.label) + .textAligned(.center) + Text(subtitle) + .font(UIFont.systemFont(ofSize: 11, weight: .regular)) + .foregroundColor(.secondaryLabel) + .textAligned(.center) + } + Text("Tap to animate") + .font(UIFont.systemFont(ofSize: 10, weight: .medium)) + .foregroundColor(.systemBlue) + .textAligned(.center) + } + .padding(.all, 12) + .background(.systemBackground) + .border(.separator, width: 1) + .cornerRadius(16) + } + } +} From 177c17e19cc8fff6b10b4d14178e1fa54a5e4888 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 17:56:11 +0900 Subject: [PATCH 29/33] Address review: fix duplicate palette color and weak self in async closure --- .../HypeUI-Example/ComponentGalleryViewController.swift | 2 +- .../HypeUI-Example/SettingsViewController.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Example/HypeUI-Example/HypeUI-Example/ComponentGalleryViewController.swift b/Example/HypeUI-Example/HypeUI-Example/ComponentGalleryViewController.swift index 0bcd3c4..ecd94f0 100644 --- a/Example/HypeUI-Example/HypeUI-Example/ComponentGalleryViewController.swift +++ b/Example/HypeUI-Example/HypeUI-Example/ComponentGalleryViewController.swift @@ -131,7 +131,7 @@ final class ComponentGalleryViewController: UIViewController { (.systemYellow, "Yellow"), (.systemGreen, "Green"), (.systemTeal, "Teal"), - (.systemTeal, "Teal 2"), + (.systemBrown, "Brown"), ] return VStack(alignment: .leading, spacing: 0) { diff --git a/Example/HypeUI-Example/HypeUI-Example/SettingsViewController.swift b/Example/HypeUI-Example/HypeUI-Example/SettingsViewController.swift index 7a8e803..11e857a 100644 --- a/Example/HypeUI-Example/HypeUI-Example/SettingsViewController.swift +++ b/Example/HypeUI-Example/HypeUI-Example/SettingsViewController.swift @@ -187,8 +187,8 @@ final class SettingsViewController: UIViewController { Button(action: { [weak self] in guard let self = self else { return } self.isSyncing = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { - self.isSyncing = false + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in + self?.isSyncing = false } }) { Text("Sync Now") From f5a9862432b2a309ab5b70b1acc8ab6f1d97a39f Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 19:12:24 +0900 Subject: [PATCH 30/33] Fix onChange target-action duplication and associated object policy - Remove previous target before adding in onChange to prevent handler firing N times when called repeatedly on the same control - Change OBJC_ASSOCIATION_COPY_NONATOMIC to OBJC_ASSOCIATION_RETAIN_NONATOMIC for closure storage; Swift closures do not conform to NSCopying - Schedule playback timer with RunLoop .common mode so progress advances during scroll interactions (was pausing in .tracking run loop mode) --- .../HypeUI-Example/MusicPlayerViewController.swift | 4 +++- Sources/HypeUI/UIPageControl+PageControl.swift | 3 ++- Sources/HypeUI/UISlider+Slider.swift | 3 ++- Sources/HypeUI/UIStepper+Stepper.swift | 3 ++- Sources/HypeUI/UISwitch+Toggle.swift | 3 ++- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift b/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift index a21d7b6..e3a2d14 100644 --- a/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift +++ b/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift @@ -255,7 +255,7 @@ final class MusicPlayerViewController: UIViewController { private func togglePlayback() { isPlaying.toggle() if isPlaying { - timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + let t = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in guard let self = self else { return } let duration = Float(self.tracks[self.currentTrack].duration) if self.progress < 1.0 { @@ -264,6 +264,8 @@ final class MusicPlayerViewController: UIViewController { self.nextTrack() } } + RunLoop.current.add(t, forMode: .common) + timer = t } else { timer?.invalidate() timer = nil diff --git a/Sources/HypeUI/UIPageControl+PageControl.swift b/Sources/HypeUI/UIPageControl+PageControl.swift index 386787b..cf839ee 100644 --- a/Sources/HypeUI/UIPageControl+PageControl.swift +++ b/Sources/HypeUI/UIPageControl+PageControl.swift @@ -24,7 +24,7 @@ public extension UIPageControl { private var onChangeAction: ((Int) -> Void)? { get { objc_getAssociatedObject(self, &uiPageControlOnChangeKey) as? (Int) -> Void } - set { objc_setAssociatedObject(self, &uiPageControlOnChangeKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } + set { objc_setAssociatedObject(self, &uiPageControlOnChangeKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } /// Sets the current page displayed by the page control. @@ -72,6 +72,7 @@ public extension UIPageControl { /// - Returns: Modified page control. func onChange(_ action: @escaping (Int) -> Void) -> Self { onChangeAction = action + removeTarget(self, action: #selector(handlePageControlValueChanged), for: .valueChanged) addTarget(self, action: #selector(handlePageControlValueChanged), for: .valueChanged) return self } diff --git a/Sources/HypeUI/UISlider+Slider.swift b/Sources/HypeUI/UISlider+Slider.swift index 9174e59..5f3d06c 100644 --- a/Sources/HypeUI/UISlider+Slider.swift +++ b/Sources/HypeUI/UISlider+Slider.swift @@ -24,7 +24,7 @@ public extension UISlider { private var onChangeAction: ((Float) -> Void)? { get { objc_getAssociatedObject(self, &uiSliderOnChangeKey) as? (Float) -> Void } - set { objc_setAssociatedObject(self, &uiSliderOnChangeKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } + set { objc_setAssociatedObject(self, &uiSliderOnChangeKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } /// Sets the current value of the slider. @@ -90,6 +90,7 @@ public extension UISlider { /// - Returns: Modified slider. func onChange(_ action: @escaping (Float) -> Void) -> Self { onChangeAction = action + removeTarget(self, action: #selector(handleSliderValueChanged), for: .valueChanged) addTarget(self, action: #selector(handleSliderValueChanged), for: .valueChanged) return self } diff --git a/Sources/HypeUI/UIStepper+Stepper.swift b/Sources/HypeUI/UIStepper+Stepper.swift index 6d64c7b..babe12b 100644 --- a/Sources/HypeUI/UIStepper+Stepper.swift +++ b/Sources/HypeUI/UIStepper+Stepper.swift @@ -24,7 +24,7 @@ public extension UIStepper { private var onChangeAction: ((Double) -> Void)? { get { objc_getAssociatedObject(self, &uiStepperOnChangeKey) as? (Double) -> Void } - set { objc_setAssociatedObject(self, &uiStepperOnChangeKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } + set { objc_setAssociatedObject(self, &uiStepperOnChangeKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } /// Sets the numeric value of the stepper. @@ -88,6 +88,7 @@ public extension UIStepper { /// - Returns: Modified stepper. func onChange(_ action: @escaping (Double) -> Void) -> Self { onChangeAction = action + removeTarget(self, action: #selector(handleStepperValueChanged), for: .valueChanged) addTarget(self, action: #selector(handleStepperValueChanged), for: .valueChanged) return self } diff --git a/Sources/HypeUI/UISwitch+Toggle.swift b/Sources/HypeUI/UISwitch+Toggle.swift index 494dedf..93bc101 100644 --- a/Sources/HypeUI/UISwitch+Toggle.swift +++ b/Sources/HypeUI/UISwitch+Toggle.swift @@ -24,7 +24,7 @@ public extension UISwitch { private var onChangeAction: ((Bool) -> Void)? { get { objc_getAssociatedObject(self, &uiSwitchOnChangeKey) as? (Bool) -> Void } - set { objc_setAssociatedObject(self, &uiSwitchOnChangeKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } + set { objc_setAssociatedObject(self, &uiSwitchOnChangeKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } /// Sets the on/off state of the switch. @@ -64,6 +64,7 @@ public extension UISwitch { /// - Returns: Modified switch. func onChange(_ action: @escaping (Bool) -> Void) -> Self { onChangeAction = action + removeTarget(self, action: #selector(handleSwitchValueChanged), for: .valueChanged) addTarget(self, action: #selector(handleSwitchValueChanged), for: .valueChanged) return self } From 6dbd1294ef8169240ce626c9014839d1d4833954 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 20:14:43 +0900 Subject: [PATCH 31/33] Fix fixedSize() to also set content hugging priority to required Content hugging was missing, allowing views to expand beyond their intrinsic size. Both compression resistance and hugging are now set to .required, matching SwiftUI fixedSize() semantics in both directions. --- Sources/HypeUI/View.swift | 8 +++++--- Tests/HypeUITests/ViewTests.swift | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/HypeUI/View.swift b/Sources/HypeUI/View.swift index a805654..d2c2194 100644 --- a/Sources/HypeUI/View.swift +++ b/Sources/HypeUI/View.swift @@ -314,13 +314,15 @@ public extension UIView { return self } - /// Fixes the view at its ideal size in the specified dimensions. - /// Sets both horizontal and vertical content compression resistance to required, - /// preventing the view from being compressed smaller than its intrinsic content size. + /// Fixes the view at its ideal size in both dimensions. + /// Sets content compression resistance and content hugging priority to required, + /// preventing the view from shrinking below or growing beyond its intrinsic content size. /// - Returns: Modified view. func fixedSize() -> Self { setContentCompressionResistancePriority(.required, for: .horizontal) setContentCompressionResistancePriority(.required, for: .vertical) + setContentHuggingPriority(.required, for: .horizontal) + setContentHuggingPriority(.required, for: .vertical) return self } } diff --git a/Tests/HypeUITests/ViewTests.swift b/Tests/HypeUITests/ViewTests.swift index 545a676..7b7e125 100644 --- a/Tests/HypeUITests/ViewTests.swift +++ b/Tests/HypeUITests/ViewTests.swift @@ -291,6 +291,8 @@ final class ViewTests: XCLayoutTestCase { // Then XCTAssertEqual(sut.contentCompressionResistancePriority(for: .horizontal), .required) XCTAssertEqual(sut.contentCompressionResistancePriority(for: .vertical), .required) + XCTAssertEqual(sut.contentHuggingPriority(for: .horizontal), .required) + XCTAssertEqual(sut.contentHuggingPriority(for: .vertical), .required) XCTAssertEqual(sut, output) } From c5b93fc38212d385021405db7a4a70f4aa5d2f79 Mon Sep 17 00:00:00 2001 From: cruz Date: Sun, 22 Mar 2026 20:30:44 +0900 Subject: [PATCH 32/33] Remove unused pageControlView weak property Page control is already updated reactively via .linked($currentTrack, keyPath: \.currentPage); the stored weak reference was never read. --- .../HypeUI-Example/MusicPlayerViewController.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift b/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift index e3a2d14..51eb3f9 100644 --- a/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift +++ b/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift @@ -39,7 +39,6 @@ final class MusicPlayerViewController: UIViewController { private let heroHeight: CGFloat = 320 private var pagingScrollView: UIScrollView! - private weak var pageControlView: UIPageControl? private var timer: Timer? private let disposeBag = DisposeBag() @@ -120,8 +119,6 @@ final class MusicPlayerViewController: UIViewController { let x = CGFloat(page) * UIScreen.main.bounds.width self.pagingScrollView?.setContentOffset(CGPoint(x: x, y: 0), animated: true) } - pageControlView = pageControl - // ── Bindings ───────────────────────────────────────────────────────── $currentTrack .map { [weak self] idx -> String? in self?.tracks[idx].title } From f6cd20264884b66494922cb1a721b99f5de4d9f5 Mon Sep 17 00:00:00 2001 From: cruz Date: Mon, 23 Mar 2026 11:27:57 +0900 Subject: [PATCH 33/33] Address review: fix shuffle replay, follower count visibility, and handler visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Exclude current track from shuffle selection in nextTrack() - Show follower count as integer below 100K to make ±1 changes visible - Mark @objc handler methods as internal to hide from public API --- .../HypeUI-Example/MusicPlayerViewController.swift | 2 +- .../HypeUI-Example/SocialProfileViewController.swift | 4 ++-- Sources/HypeUI/UIPageControl+PageControl.swift | 2 +- Sources/HypeUI/UISlider+Slider.swift | 2 +- Sources/HypeUI/UIStepper+Stepper.swift | 2 +- Sources/HypeUI/UISwitch+Toggle.swift | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift b/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift index 51eb3f9..c820a3f 100644 --- a/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift +++ b/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift @@ -272,7 +272,7 @@ final class MusicPlayerViewController: UIViewController { private func nextTrack() { progress = 0 currentTrack = isShuffle - ? Int.random(in: 0.. String? in - count >= 1000 ? String(format: "%.1fK", Double(count) / 1000) : "\(count)" + count >= 100_000 ? String(format: "%.1fK", Double(count) / 1000) : "\(count)" } .observe(on: MainScheduler.instance) .bind(to: followerLabel.rx.text) diff --git a/Sources/HypeUI/UIPageControl+PageControl.swift b/Sources/HypeUI/UIPageControl+PageControl.swift index cf839ee..b71f309 100644 --- a/Sources/HypeUI/UIPageControl+PageControl.swift +++ b/Sources/HypeUI/UIPageControl+PageControl.swift @@ -77,7 +77,7 @@ public extension UIPageControl { return self } - @objc func handlePageControlValueChanged() { + @objc internal func handlePageControlValueChanged() { onChangeAction?(currentPage) } } diff --git a/Sources/HypeUI/UISlider+Slider.swift b/Sources/HypeUI/UISlider+Slider.swift index 5f3d06c..c052583 100644 --- a/Sources/HypeUI/UISlider+Slider.swift +++ b/Sources/HypeUI/UISlider+Slider.swift @@ -95,7 +95,7 @@ public extension UISlider { return self } - @objc func handleSliderValueChanged() { + @objc internal func handleSliderValueChanged() { onChangeAction?(value) } } diff --git a/Sources/HypeUI/UIStepper+Stepper.swift b/Sources/HypeUI/UIStepper+Stepper.swift index babe12b..94a2e11 100644 --- a/Sources/HypeUI/UIStepper+Stepper.swift +++ b/Sources/HypeUI/UIStepper+Stepper.swift @@ -93,7 +93,7 @@ public extension UIStepper { return self } - @objc func handleStepperValueChanged() { + @objc internal func handleStepperValueChanged() { onChangeAction?(value) } } diff --git a/Sources/HypeUI/UISwitch+Toggle.swift b/Sources/HypeUI/UISwitch+Toggle.swift index 93bc101..57b509f 100644 --- a/Sources/HypeUI/UISwitch+Toggle.swift +++ b/Sources/HypeUI/UISwitch+Toggle.swift @@ -69,7 +69,7 @@ public extension UISwitch { return self } - @objc func handleSwitchValueChanged() { + @objc internal func handleSwitchValueChanged() { onChangeAction?(isOn) } }