diff --git a/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.pbxproj b/Example/HypeUI-Example/HypeUI-Example.xcodeproj/project.pbxproj index 00365cf..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 */, ); @@ -377,7 +405,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/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) + } + } +} diff --git a/Example/HypeUI-Example/HypeUI-Example/ComponentGalleryViewController.swift b/Example/HypeUI-Example/HypeUI-Example/ComponentGalleryViewController.swift new file mode 100644 index 0000000..ecd94f0 --- /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"), + (.systemBrown, "Brown"), + ] + + 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) + } + } +} diff --git a/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift b/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift new file mode 100644 index 0000000..c820a3f --- /dev/null +++ b/Example/HypeUI-Example/HypeUI-Example/MusicPlayerViewController.swift @@ -0,0 +1,311 @@ +// +// 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 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 { + 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 { + self.progress = min(1.0, self.progress + (0.1 / duration)) + } else { + self.nextTrack() + } + } + RunLoop.current.add(t, forMode: .common) + timer = t + } else { + timer?.invalidate() + timer = nil + } + } + + private func nextTrack() { + progress = 0 + currentTrack = isShuffle + ? (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 + } + } +} 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/SettingsViewController.swift b/Example/HypeUI-Example/HypeUI-Example/SettingsViewController.swift new file mode 100644 index 0000000..11e857a --- /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) { [weak self] in + 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) + } +} diff --git a/Example/HypeUI-Example/HypeUI-Example/SocialProfileViewController.swift b/Example/HypeUI-Example/HypeUI-Example/SocialProfileViewController.swift new file mode 100644 index 0000000..d116363 --- /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("12400") + .font(UIFont.systemFont(ofSize: 18, weight: .bold)) + .foregroundColor(.label) + .textAligned(.center) + + $followerCount + .map { count -> String? in + count >= 100_000 ? 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 + } + } + } + } + } +} 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)) + } +} 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) + } } } 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 } - 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 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) + } + } } 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 + } } 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/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 + } +} diff --git a/Sources/HypeUI/UIPageControl+PageControl.swift b/Sources/HypeUI/UIPageControl+PageControl.swift new file mode 100644 index 0000000..b71f309 --- /dev/null +++ b/Sources/HypeUI/UIPageControl+PageControl.swift @@ -0,0 +1,83 @@ +// +// 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: - UIPageControl (PageControl) + +private var uiPageControlOnChangeKey: UInt8 = 0 + +public extension UIPageControl { + + private var onChangeAction: ((Int) -> Void)? { + get { objc_getAssociatedObject(self, &uiPageControlOnChangeKey) as? (Int) -> Void } + set { objc_setAssociatedObject(self, &uiPageControlOnChangeKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + /// 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 { + onChangeAction = action + removeTarget(self, action: #selector(handlePageControlValueChanged), for: .valueChanged) + addTarget(self, action: #selector(handlePageControlValueChanged), for: .valueChanged) + return self + } + + @objc internal func handlePageControlValueChanged() { + onChangeAction?(currentPage) + } +} 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 + } +} diff --git a/Sources/HypeUI/UISlider+Slider.swift b/Sources/HypeUI/UISlider+Slider.swift new file mode 100644 index 0000000..c052583 --- /dev/null +++ b/Sources/HypeUI/UISlider+Slider.swift @@ -0,0 +1,101 @@ +// +// 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: - UISlider (Slider) + +private var uiSliderOnChangeKey: UInt8 = 0 + +public extension UISlider { + + private var onChangeAction: ((Float) -> Void)? { + get { objc_getAssociatedObject(self, &uiSliderOnChangeKey) as? (Float) -> Void } + set { objc_setAssociatedObject(self, &uiSliderOnChangeKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + /// 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 { + onChangeAction = action + removeTarget(self, action: #selector(handleSliderValueChanged), for: .valueChanged) + addTarget(self, action: #selector(handleSliderValueChanged), for: .valueChanged) + return self + } + + @objc internal func handleSliderValueChanged() { + onChangeAction?(value) + } +} diff --git a/Sources/HypeUI/UIStepper+Stepper.swift b/Sources/HypeUI/UIStepper+Stepper.swift new file mode 100644 index 0000000..94a2e11 --- /dev/null +++ b/Sources/HypeUI/UIStepper+Stepper.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 UIKit + +// MARK: - UIStepper (Stepper) + +private var uiStepperOnChangeKey: UInt8 = 0 + +public extension UIStepper { + + private var onChangeAction: ((Double) -> Void)? { + get { objc_getAssociatedObject(self, &uiStepperOnChangeKey) as? (Double) -> Void } + set { objc_setAssociatedObject(self, &uiStepperOnChangeKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + /// 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 { + onChangeAction = action + removeTarget(self, action: #selector(handleStepperValueChanged), for: .valueChanged) + addTarget(self, action: #selector(handleStepperValueChanged), for: .valueChanged) + return self + } + + @objc internal func handleStepperValueChanged() { + onChangeAction?(value) + } +} diff --git a/Sources/HypeUI/UISwitch+Toggle.swift b/Sources/HypeUI/UISwitch+Toggle.swift new file mode 100644 index 0000000..57b509f --- /dev/null +++ b/Sources/HypeUI/UISwitch+Toggle.swift @@ -0,0 +1,75 @@ +// +// 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: - UISwitch (Toggle) + +private var uiSwitchOnChangeKey: UInt8 = 0 + +public extension UISwitch { + + private var onChangeAction: ((Bool) -> Void)? { + get { objc_getAssociatedObject(self, &uiSwitchOnChangeKey) as? (Bool) -> Void } + set { objc_setAssociatedObject(self, &uiSwitchOnChangeKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + /// 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 { + onChangeAction = action + removeTarget(self, action: #selector(handleSwitchValueChanged), for: .valueChanged) + addTarget(self, action: #selector(handleSwitchValueChanged), for: .valueChanged) + return self + } + + @objc internal func handleSwitchValueChanged() { + onChangeAction?(isOn) + } +} diff --git a/Sources/HypeUI/View.swift b/Sources/HypeUI/View.swift index 464b88c..d2c2194 100644 --- a/Sources/HypeUI/View.swift +++ b/Sources/HypeUI/View.swift @@ -281,16 +281,86 @@ 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 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 + } } // 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/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) + } +} 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) + } } 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) + } +} diff --git a/Tests/HypeUITests/UIPageControl+PageControlTests.swift b/Tests/HypeUITests/UIPageControl+PageControlTests.swift new file mode 100644 index 0000000..900b88d --- /dev/null +++ b/Tests/HypeUITests/UIPageControl+PageControlTests.swift @@ -0,0 +1,101 @@ +// +// 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() + contentView.addSubview(sut) + sut.numberOfPages = 5 + var receivedPage: Int? + + // when + let output = sut.onChange { receivedPage = $0 } + sut.currentPage = 3 + sut.handlePageControlValueChanged() + + // then + XCTAssertEqual(receivedPage, 3) + XCTAssertEqual(sut, output) + } +} 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) + } +} diff --git a/Tests/HypeUITests/UISlider+SliderTests.swift b/Tests/HypeUITests/UISlider+SliderTests.swift new file mode 100644 index 0000000..d94fd6c --- /dev/null +++ b/Tests/HypeUITests/UISlider+SliderTests.swift @@ -0,0 +1,123 @@ +// +// 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() + contentView.addSubview(sut) + var receivedValue: Float? + + // when + let output = sut.onChange { receivedValue = $0 } + sut.value = 0.75 + sut.handleSliderValueChanged() + + // then + XCTAssertEqual(receivedValue, 0.75) + XCTAssertEqual(sut, output) + } +} diff --git a/Tests/HypeUITests/UIStepper+StepperTests.swift b/Tests/HypeUITests/UIStepper+StepperTests.swift new file mode 100644 index 0000000..4bb853b --- /dev/null +++ b/Tests/HypeUITests/UIStepper+StepperTests.swift @@ -0,0 +1,123 @@ +// +// 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() + contentView.addSubview(sut) + var receivedValue: Double? + + // when + let output = sut.onChange { receivedValue = $0 } + sut.value = 3.0 + sut.handleStepperValueChanged() + + // then + XCTAssertEqual(receivedValue, 3.0) + XCTAssertEqual(sut, output) + } +} diff --git a/Tests/HypeUITests/UISwitch+ToggleTests.swift b/Tests/HypeUITests/UISwitch+ToggleTests.swift new file mode 100644 index 0000000..0fb9b0a --- /dev/null +++ b/Tests/HypeUITests/UISwitch+ToggleTests.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: - 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() + contentView.addSubview(sut) + var receivedValue: Bool? + + // when + let output = sut.onChange { receivedValue = $0 } + sut.isOn = true + sut.handleSwitchValueChanged() + + // then + XCTAssertTrue(receivedValue == true) + XCTAssertEqual(sut, output) + } +} diff --git a/Tests/HypeUITests/ViewTests.swift b/Tests/HypeUITests/ViewTests.swift index 6398774..7b7e125 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() { @@ -188,7 +190,7 @@ final class ViewTests: XCLayoutTestCase { // Given let sut = Text() .frame(width: 200, height: 200) - + // When contentView.addSubviewWithFit( ZStack { @@ -198,9 +200,145 @@ final class ViewTests: XCLayoutTestCase { }.center() ) 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)) } + + 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.contentHuggingPriority(for: .horizontal), .required) + XCTAssertEqual(sut.contentHuggingPriority(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) + } }