├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── LICENSE.txt ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── SwiftfulUI │ ├── AsyncViewBuilders │ ├── AsyncButton.swift │ ├── AsyncLetViewBuilder.swift │ └── AsyncViewBuilder.swift │ ├── Backgrounds │ ├── BackgroundBorderAndFillViewModifier.swift │ ├── BackgroundBorderAndLinearGradientFillViewModifier.swift │ ├── BackgroundBorderViewModifier.swift │ ├── BackgroundFillViewModifier.swift │ └── BackgroundLinearGradientFillViewModifier.swift │ ├── Buttons │ └── ButtonStyleViewModifier.swift │ ├── Fonts │ ├── FontAnimatableViewModifier.swift │ └── FontRegularViewModifier.swift │ ├── GeometryReaders │ ├── FrameReader.swift │ └── LocationReader.swift │ ├── Gestures │ ├── DragGestureViewModifier.swift │ ├── MagnificationGestureViewModifier.swift │ └── RotationGestureViewModifier.swift │ ├── Grids │ ├── NonLazyHGrid.swift │ └── NonLazyVGrid.swift │ ├── Haptics │ ├── HapticOption.swift │ ├── HapticViewModifier.swift │ └── Haptics.swift │ ├── ProgressBars │ └── CustomProgressBar.swift │ ├── Redacted │ └── RedactedViewModifier.swift │ ├── ScrollViews │ ├── ScrollViewWithOnScrollChanged.swift │ └── ScrollViewWithScrollToLocation.swift │ ├── Stacks │ └── LazyZStack.swift │ ├── TabBars │ ├── TabBarDefaultView.swift │ ├── TabBarItem.swift │ ├── TabBarItemViewModifier.swift │ └── TabBarViewBuilder.swift │ ├── Toggles │ └── CustomToggle.swift │ ├── ViewModifiers │ ├── AnyNotificationListenerViewModifier.swift │ ├── OnFirstAppearViewModifier.swift │ ├── OnFirstDisappearViewModifier.swift │ └── StetchyHeaderViewModifier.swift │ └── Views │ ├── CountdownViewBuilder.swift │ ├── FlipView.swift │ └── RootView.swift └── Tests └── SwiftfulUITests └── SwiftfulUITests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Swiftful Thinking, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Swiftful Thinking, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "SwiftfulRecursiveUI", 6 | "repositoryURL": "https://github.com/SwiftfulThinking/SwiftfulRecursiveUI.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "3d2d5ac6efe70874fb5ffb2f8e1d5ccd457f4366", 10 | "version": "1.0.2" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SwiftfulUI", 8 | platforms: [ 9 | .macOS(.v10_14), .iOS(.v13), .tvOS(.v13) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "SwiftfulUI", 15 | targets: ["SwiftfulUI"]), 16 | ], 17 | dependencies: [ 18 | .package(url: "https://github.com/SwiftfulThinking/SwiftfulRecursiveUI.git", from: "1.0.0") 19 | ], 20 | targets: [ 21 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 22 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 23 | .target( 24 | name: "SwiftfulUI", 25 | dependencies: [ 26 | .product(name: "SwiftfulRecursiveUI", package: "SwiftfulRecursiveUI") 27 | ]), 28 | .testTarget( 29 | name: "SwiftfulUITests", 30 | dependencies: ["SwiftfulUI"]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftfulUI 2 | 3 | YouTube Tutorial: https://www.youtube.com/watch?v=KjmtLrs6ICM&list=PLwvDm4VfkdphPRGbtiY-X3IZsUXFi6595&index=5 4 | 5 | A library of reusable SwiftUI components that are missing from the SwiftUI framework. 6 | - See the [Wiki](https://github.com/SwiftfulThinking/SwiftfulUI/wiki) for full documentation. 7 | - See the SwiftUI Previews within source files for example implementations. 8 | 9 | ## Table of Contents 10 | - [AsyncViewBuilders](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/AsyncViewBuilders) 11 | - [Backgrounds & Borders](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/Backgrounds-&-Borders) 12 | - [Buttons](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/Buttons) 13 | - [Fonts](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/Fonts) 14 | - [GeometryReaders](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/GeometryReaders) 15 | - [Gestures](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/Gestures) 16 | - [Grids](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/Grids) 17 | - [Haptics](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/Haptics) 18 | - [Progress Bars](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/Progress-Bars) 19 | - [Redacted](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/Redacted) 20 | - [ScrollViews](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/ScrollViews) 21 | - [Stacks](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/Stacks) 22 | - [TabBar](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/TabBar) 23 | - [Toggles](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/Toggles) 24 | - [ViewModifiers](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/ViewModifiers) 25 | - [Views](https://github.com/SwiftfulThinking/SwiftfulUI/wiki/Views-(RootView,-CountdownViewBuilder,-etc.)) 26 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/AsyncViewBuilders/AsyncButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncButton.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 11/12/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | // credit: Ricky Stone 12 | 13 | public struct AsyncButton: View { 14 | let action: () async -> Void 15 | let label: (Bool) -> Label 16 | @State private var task: Task<(), Never>? = nil 17 | 18 | public init(action: @escaping () async -> Void, label: @escaping (Bool) -> Label) { 19 | self.action = action 20 | self.label = label 21 | } 22 | 23 | public var body: some View { 24 | Button(action: { 25 | task = Task { @MainActor in 26 | await action() 27 | task = nil 28 | } 29 | }, label: { 30 | label(task != nil) 31 | }) 32 | .onDisappear { 33 | task?.cancel() 34 | task = nil 35 | } 36 | } 37 | } 38 | 39 | #Preview("AsyncButton") { 40 | AsyncButton { 41 | try? await Task.sleep(nanoseconds: 2_000_000_000) 42 | } label: { isPerformingAction in 43 | ZStack { 44 | if isPerformingAction { 45 | if #available(iOS 14.0, *) { 46 | ProgressView() 47 | } 48 | } 49 | 50 | Text("Hello, world!") 51 | .opacity(isPerformingAction ? 0 : 1) 52 | } 53 | .foregroundColor(.white) 54 | .font(.headline) 55 | .padding(.vertical, 16) 56 | .frame(maxWidth: .infinity) 57 | .background(Color.blue) 58 | .cornerRadius(10) 59 | .disabled(isPerformingAction) 60 | } 61 | .padding() 62 | } 63 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/AsyncViewBuilders/AsyncLetViewBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncLetViewBuilder.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 3/30/22. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// Load any View from two asynchronous methods concurrently. 12 | /// 13 | /// The fetch requests are called every time the view appears, unless data has already been successfully loaded. The Tasks are cancelled when the view disappears. 14 | @available(iOS 14, *) 15 | public struct AsyncLetViewBuilder: View { 16 | 17 | public enum AsyncLetLoadingPhase { 18 | /// No value is loaded. 19 | case loading 20 | /// A value successfully loaded. 21 | case success(valueA: A, valueB: B) 22 | /// A value failed to load with an error. 23 | case failure(error: Error?) 24 | } 25 | 26 | @State private var task: Task? = nil 27 | @State private var phase: AsyncLetLoadingPhase = .loading 28 | var priority: TaskPriority = .userInitiated 29 | var redactedStyle: RedactedStyle = .never 30 | var redactedOnFailure: Bool = false 31 | let fetchA: () async throws -> A 32 | let fetchB: () async throws -> B 33 | let content: (AsyncLetLoadingPhase) -> any View 34 | 35 | public init(priority: TaskPriority = .userInitiated, redactedStyle: RedactedStyle = .never, redactedOnFailure: Bool = false, fetchA: @escaping () async throws -> A, fetchB: @escaping () async throws -> B, content: @escaping (AsyncLetLoadingPhase) -> any View) { 36 | self.priority = priority 37 | self.redactedStyle = redactedStyle 38 | self.redactedOnFailure = redactedOnFailure 39 | self.fetchA = fetchA 40 | self.fetchB = fetchB 41 | self.content = content 42 | } 43 | 44 | public var body: some View { 45 | if #available(iOS 15.0, *) { 46 | AnyView(content(phase)) 47 | .redacted(if: shouldBeRedacted, style: redactedStyle) 48 | .task(priority: priority) { 49 | await performFetchRequestIfNeeded() 50 | } 51 | } else { 52 | AnyView(content(phase)) 53 | .redacted(if: shouldBeRedacted, style: redactedStyle) 54 | .onAppear { 55 | task = Task(priority: priority) { 56 | await performFetchRequestIfNeeded() 57 | } 58 | } 59 | .onDisappear { 60 | task?.cancel() 61 | } 62 | } 63 | } 64 | 65 | private func performFetchRequestIfNeeded() async { 66 | // This will be called every time the view appears. 67 | // If we already performed a successful fetch, there's no need to refetch. 68 | switch phase { 69 | case .loading, .failure: 70 | break 71 | case .success: 72 | return 73 | } 74 | 75 | do { 76 | async let fetchA = await fetchA() 77 | async let fetchB = await fetchB() 78 | phase = await .success(valueA: try fetchA, valueB: try fetchB) 79 | } catch { 80 | phase = .failure(error: error) 81 | } 82 | } 83 | 84 | private var shouldBeRedacted: Bool { 85 | switch phase { 86 | case .loading: return true 87 | case .success: return false 88 | case .failure: return redactedOnFailure 89 | } 90 | } 91 | } 92 | 93 | #Preview { 94 | if #available(iOS 14, *) { 95 | return AsyncLetViewBuilder( 96 | priority: .high, 97 | redactedStyle: .never, 98 | redactedOnFailure: true, 99 | fetchA: { 100 | try? await Task.sleep(nanoseconds: 1_000_000_000) 101 | return "Alpha" 102 | }, 103 | fetchB: { 104 | try? await Task.sleep(nanoseconds: 2_000_000_000) 105 | return "Beta" 106 | }, 107 | content: { phase in 108 | ZStack { 109 | switch phase { 110 | case .loading: 111 | Text("Loading") 112 | case .success(let a, let b): 113 | HStack { 114 | Text(a) 115 | Text(b) 116 | } 117 | case .failure: 118 | Text("FAILURE") 119 | } 120 | } 121 | } 122 | ) 123 | } else { 124 | return Text("err") 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/AsyncViewBuilders/AsyncViewBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncViewBuilder.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 3/30/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Load any View from an asynchronous method. 11 | /// 12 | /// The fetch request is called every time the view appears, unless data has already been successfully loaded. The Task is cancelled when the view disappears. 13 | @available(iOS 14, *) 14 | public struct AsyncViewBuilder: View { 15 | 16 | public enum AsyncLoadingPhase { 17 | /// No value is loaded. 18 | case loading 19 | /// A value successfully loaded. 20 | case success(value: T) 21 | /// A value failed to load with an error. 22 | case failure(error: Error?) 23 | } 24 | 25 | @State private var task: Task? = nil 26 | @State private var phase: AsyncLoadingPhase = .loading 27 | var priority: TaskPriority = .userInitiated 28 | var redactedStyle: RedactedStyle = .never 29 | var redactedOnFailure: Bool = false 30 | let fetch: () async throws -> T 31 | let content: (AsyncLoadingPhase) -> any View 32 | 33 | public init(priority: TaskPriority = .userInitiated, redactedStyle: RedactedStyle = .never, redactedOnFailure: Bool = false, fetch: @escaping () async throws -> T, content: @escaping (AsyncLoadingPhase) -> any View) { 34 | self.priority = priority 35 | self.redactedStyle = redactedStyle 36 | self.redactedOnFailure = redactedOnFailure 37 | self.fetch = fetch 38 | self.content = content 39 | } 40 | 41 | public var body: some View { 42 | if #available(iOS 15.0, *) { 43 | AnyView(content(phase)) 44 | .redacted(if: shouldBeRedacted, style: redactedStyle) 45 | .task(priority: priority) { 46 | await performFetchRequestIfNeeded() 47 | } 48 | } else { 49 | AnyView(content(phase)) 50 | .redacted(if: shouldBeRedacted, style: redactedStyle) 51 | .onAppear { 52 | task = Task(priority: priority) { 53 | await performFetchRequestIfNeeded() 54 | } 55 | } 56 | .onDisappear { 57 | task?.cancel() 58 | } 59 | } 60 | } 61 | 62 | private func performFetchRequestIfNeeded() async { 63 | // This will be called every time the view appears. 64 | // If we already performed a successful fetch, there's no need to refetch. 65 | switch phase { 66 | case .loading, .failure: 67 | break 68 | case .success: 69 | return 70 | } 71 | 72 | do { 73 | phase = .success(value: try await fetch()) 74 | } catch { 75 | phase = .failure(error: error) 76 | } 77 | } 78 | 79 | private var shouldBeRedacted: Bool { 80 | switch phase { 81 | case .loading: return true 82 | case .success: return false 83 | case .failure: return redactedOnFailure 84 | } 85 | } 86 | } 87 | 88 | #Preview { 89 | if #available(iOS 14, *) { 90 | return AsyncViewBuilder( 91 | priority: .high, 92 | redactedStyle: .never, 93 | redactedOnFailure: true, 94 | fetch: { 95 | try? await Task.sleep(nanoseconds: 2_000_000_000) 96 | return "heart.fill" 97 | }, content: { phase in 98 | ZStack { 99 | switch phase { 100 | case .loading: 101 | Image(systemName: "house.fill") 102 | .resizable() 103 | .scaledToFit() 104 | .frame(width: 200, height: 200) 105 | case .success(let imageName): 106 | Image(systemName: imageName) 107 | .resizable() 108 | .scaledToFit() 109 | .frame(width: 200, height: 200) 110 | case .failure: 111 | Text("FAILURE") 112 | } 113 | } 114 | } 115 | ) 116 | } else { 117 | return Text("err") 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Backgrounds/BackgroundBorderAndFillViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundBorderAndFillViewModifier.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/3/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BackgroundBorderAndFillViewModifier: ViewModifier { 11 | 12 | let backgroundColor: Color 13 | let borderColor: Color 14 | let borderWidth: CGFloat 15 | let cornerRadius: CGFloat 16 | 17 | func body(content: Content) -> some View { 18 | content 19 | .background(Color.white.opacity(0.0001)) 20 | .padding(-borderWidth) 21 | .background( 22 | RoundedRectangle(cornerRadius: cornerRadius - (borderWidth / 2)) 23 | .fill(backgroundColor) 24 | ) 25 | .padding(borderWidth) 26 | .background( 27 | RoundedRectangle(cornerRadius: cornerRadius) 28 | .fill(borderColor) 29 | ) 30 | } 31 | 32 | } 33 | 34 | public extension View { 35 | 36 | /// Add a colored border to the background. 37 | func withBackgroundAndBorder( 38 | backgroundColor: Color, 39 | borderColor: Color, 40 | borderWidth: CGFloat, 41 | cornerRadius: CGFloat = 0) -> some View { 42 | modifier(BackgroundBorderAndFillViewModifier(backgroundColor: backgroundColor, borderColor: borderColor, borderWidth: borderWidth, cornerRadius: cornerRadius)) 43 | } 44 | 45 | } 46 | 47 | struct BackgroundBorderAndFillViewModifier_Previews: PreviewProvider { 48 | 49 | struct PreviewView: View { 50 | @State private var isActive: Bool = false 51 | var body: some View { 52 | Text("Hello, world") 53 | .frame(width: 100, height: 100) 54 | .withBackgroundAndBorder( 55 | backgroundColor: .blue, 56 | borderColor: isActive ? .red : .blue, 57 | borderWidth: 5, cornerRadius: 10) 58 | .onTapGesture { 59 | withAnimation { 60 | isActive.toggle() 61 | } 62 | } 63 | } 64 | } 65 | 66 | static var previews: some View { 67 | VStack { 68 | PreviewView() 69 | 70 | Text("Hello, world") 71 | .frame(width: 100, height: 100) 72 | .background(Color.orange) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Backgrounds/BackgroundBorderAndLinearGradientFillViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundBorderAndLinearGradientFillViewModifier.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/3/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BackgroundBorderAndLinearGradientFillViewModifier: ViewModifier { 11 | 12 | let isActive: Bool 13 | let borderGradient: LinearGradient 14 | let borderWidth: CGFloat 15 | let backgroundColor: Color 16 | let cornerRadius: CGFloat 17 | 18 | 19 | func body(content: Content) -> some View { 20 | content 21 | .background(Color.white.opacity(0.0001)) 22 | .padding(-borderWidth) 23 | .background( 24 | ZStack { 25 | if !isActive { 26 | RoundedRectangle(cornerRadius: cornerRadius - (borderWidth / 2)) 27 | .fill(backgroundColor) 28 | } 29 | } 30 | ) 31 | .padding(borderWidth) 32 | .background( 33 | RoundedRectangle(cornerRadius: cornerRadius) 34 | .fill(borderGradient) 35 | ) 36 | } 37 | 38 | } 39 | public extension View { 40 | 41 | /// Add a linear gradient border and animate as the background if needed. 42 | func withGradientBorder( 43 | isActive: Bool = false, 44 | borderGradient: LinearGradient, 45 | borderWidth: CGFloat, 46 | backgroundColor: Color = .white, 47 | cornerRadius: CGFloat = 0) -> some View { 48 | modifier(BackgroundBorderAndLinearGradientFillViewModifier(isActive: isActive, borderGradient: borderGradient, borderWidth: borderWidth, backgroundColor: backgroundColor, cornerRadius: cornerRadius)) 49 | } 50 | 51 | 52 | 53 | } 54 | 55 | struct BackgroundBorderAndLinearGradientFillViewModifier_Previews: PreviewProvider { 56 | 57 | struct PreviewView: View { 58 | @State private var isActive: Bool = false 59 | var body: some View { 60 | Text("Hello, world") 61 | .padding(.horizontal) 62 | .padding(.all) 63 | .withGradientBorder( 64 | isActive: isActive, 65 | borderGradient: LinearGradient(colors: [Color.blue.opacity(0.3), .blue], startPoint: .topLeading, endPoint: .trailing), borderWidth: 2, cornerRadius: 15) 66 | .onTapGesture { 67 | withAnimation { 68 | isActive.toggle() 69 | } 70 | } 71 | } 72 | } 73 | 74 | static var previews: some View { 75 | VStack { 76 | PreviewView() 77 | 78 | Text("Hello, world") 79 | .frame(width: 100, height: 100) 80 | .background(Color.orange) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Backgrounds/BackgroundBorderViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundBorderViewModifier.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/3/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BackgroundBorderViewModifier: ViewModifier { 11 | 12 | let borderColor: Color 13 | let borderWidth: CGFloat 14 | let cornerRadius: CGFloat 15 | 16 | func body(content: Content) -> some View { 17 | content 18 | .background(Color.white.opacity(0.0001)) 19 | .background( 20 | RoundedRectangle(cornerRadius: cornerRadius) 21 | .strokeBorder(borderColor, lineWidth: borderWidth) 22 | ) 23 | } 24 | 25 | } 26 | 27 | public extension View { 28 | 29 | /// Add a colored border to the background. 30 | func withBorder( 31 | color: Color, 32 | width: CGFloat, 33 | cornerRadius: CGFloat = 0) -> some View { 34 | modifier(BackgroundBorderViewModifier(borderColor: color, borderWidth: width, cornerRadius: cornerRadius)) 35 | } 36 | 37 | } 38 | 39 | struct BackgroundBorderViewModifier_Previews: PreviewProvider { 40 | 41 | struct PreviewView: View { 42 | @State private var isActive: Bool = false 43 | var body: some View { 44 | Text("Hello, world") 45 | .frame(width: 100, height: 100) 46 | .withBorder(color: isActive ? .blue : .red, width: 2, cornerRadius: 10) 47 | .onTapGesture { 48 | withAnimation { 49 | isActive.toggle() 50 | } 51 | } 52 | } 53 | } 54 | 55 | static var previews: some View { 56 | VStack { 57 | PreviewView() 58 | 59 | Text("Hello, world") 60 | .frame(width: 100, height: 100) 61 | .background(Color.orange) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Backgrounds/BackgroundFillViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundFillViewModifier.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/3/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BackgroundFillViewModifier: ViewModifier { 11 | 12 | let color: Color 13 | let cornerRadius: CGFloat 14 | 15 | func body(content: Content) -> some View { 16 | content 17 | .background(color) 18 | .cornerRadius(cornerRadius) 19 | } 20 | 21 | } 22 | 23 | public extension View { 24 | 25 | /// Add a color background with corner radius. 26 | func withBackground(color: Color, cornerRadius: CGFloat = 0) -> some View { 27 | modifier(BackgroundFillViewModifier(color: color, cornerRadius: cornerRadius)) 28 | } 29 | 30 | } 31 | 32 | struct BackgroundFillViewModifier_Previews: PreviewProvider { 33 | static var previews: some View { 34 | Text("Hello, world") 35 | .withBackground(color: .red, cornerRadius: 30) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Backgrounds/BackgroundLinearGradientFillViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundLinearGradientFillViewModifier.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/3/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BackgroundLinearGradientFillViewModifier: ViewModifier { 11 | 12 | let isActive: Bool 13 | let activeGradient: LinearGradient 14 | let defaultGradient: LinearGradient? 15 | let cornerRadius: CGFloat 16 | 17 | func body(content: Content) -> some View { 18 | content 19 | .background( 20 | ZStack { 21 | if isActive { 22 | activeGradient 23 | .transition(AnyTransition.opacity.animation(.linear)) 24 | } 25 | if !isActive, let defaultGradient = defaultGradient { 26 | defaultGradient 27 | .transition(AnyTransition.opacity.animation(.linear)) 28 | } 29 | } 30 | ) 31 | .background(defaultGradient != nil ? Color.gray : .clear) 32 | .cornerRadius(cornerRadius) 33 | } 34 | 35 | } 36 | 37 | public extension View { 38 | 39 | /// Add a linear gradient background. 40 | func withGradientBackground( 41 | gradient: LinearGradient, 42 | cornerRadius: CGFloat = 0) -> some View { 43 | modifier(BackgroundLinearGradientFillViewModifier(isActive: true, activeGradient: gradient, defaultGradient: nil, cornerRadius: cornerRadius)) 44 | } 45 | 46 | /// Add a linear gradient background that can animate between gradients. 47 | func withGradientBackgroundAnimatable( 48 | isActive: Bool, 49 | activeGradient: LinearGradient, 50 | defaultGradient: LinearGradient, 51 | cornerRadius: CGFloat = 0) -> some View { 52 | modifier(BackgroundLinearGradientFillViewModifier(isActive: isActive, activeGradient: activeGradient, defaultGradient: defaultGradient, cornerRadius: cornerRadius)) 53 | } 54 | 55 | } 56 | 57 | struct BackgroundLinearGradientFillViewModifier_Previews: PreviewProvider { 58 | 59 | struct PreviewView: View { 60 | @State private var isActive: Bool = false 61 | var body: some View { 62 | Text("Hello, world") 63 | .padding() 64 | .withGradientBackgroundAnimatable(isActive: isActive, activeGradient: LinearGradient(colors: [Color.red, .blue], startPoint: .leading, endPoint: .trailing), defaultGradient: LinearGradient(colors: [Color.green, .orange], startPoint: .leading, endPoint: .trailing), cornerRadius: 10) 65 | .onTapGesture { 66 | withAnimation { 67 | isActive.toggle() 68 | } 69 | } 70 | } 71 | } 72 | 73 | static var previews: some View { 74 | PreviewView() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Buttons/ButtonStyleViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/8/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ButtonStyleViewModifier: ButtonStyle { 11 | 12 | let scale: CGFloat 13 | let opacity: Double 14 | let brightness: Double 15 | 16 | func makeBody(configuration: Configuration) -> some View { 17 | configuration.label 18 | .scaleEffect(configuration.isPressed ? scale : 1) 19 | .opacity(configuration.isPressed ? opacity : 1) 20 | .brightness(configuration.isPressed ? brightness : 0) 21 | } 22 | 23 | // Note: Can add onChange to let isPressed value escape. 24 | // However, requires iOS 14 is not common use case. 25 | // Ignoring for now. 26 | // .onChange(of: configuration.isPressed) { newValue in 27 | // isPressed?(newValue) 28 | // } 29 | 30 | } 31 | 32 | public enum ButtonType { 33 | case press, opacity, tap 34 | } 35 | 36 | public extension View { 37 | 38 | /// Wrap a View in a Button and add a custom ButtonStyle. 39 | func asButton(scale: CGFloat = 0.95, opacity: Double = 1, brightness: Double = 0, action: @escaping () -> Void) -> some View { 40 | Button(action: { 41 | action() 42 | }, label: { 43 | self 44 | }) 45 | .buttonStyle(ButtonStyleViewModifier(scale: scale, opacity: opacity, brightness: brightness)) 46 | } 47 | 48 | @ViewBuilder 49 | func asButton(_ type: ButtonType = .tap, action: @escaping () -> Void) -> some View { 50 | switch type { 51 | case .press: 52 | self.asButton(scale: 0.975, action: action) 53 | case .opacity: 54 | self.asButton(scale: 1, opacity: 0.85, action: action) 55 | case .tap: 56 | self.onTapGesture { 57 | action() 58 | } 59 | } 60 | } 61 | 62 | } 63 | 64 | @available(iOS 14, *) 65 | public extension View { 66 | 67 | /// Wrap a View in a Link and add a custom ButtonStyle. 68 | @ViewBuilder 69 | func asWebLink(scale: CGFloat = 0.95, opacity: Double = 1, brightness: Double = 0, url: @escaping () -> URL?) -> some View { 70 | if let url = url() { 71 | Link(destination: url) { 72 | self 73 | } 74 | .buttonStyle(ButtonStyleViewModifier(scale: scale, opacity: opacity, brightness: brightness)) 75 | } else { 76 | self 77 | .onAppear { 78 | print("‼️ SwiftfulUI Warning: URL is nil! \(#file) \(#line)") 79 | } 80 | } 81 | } 82 | 83 | } 84 | 85 | @available(iOS 14, *) 86 | struct ButtonStyleViewModifier_Previews: PreviewProvider { 87 | 88 | static private func someButtonLabel() -> some View { 89 | Text("Hello") 90 | .foregroundColor(.white) 91 | .frame(height: 52) 92 | .frame(maxWidth: .infinity) 93 | .background(Color.blue) 94 | .cornerRadius(10) 95 | } 96 | 97 | static var previews: some View { 98 | ZStack { 99 | Color.green.ignoresSafeArea() 100 | 101 | VStack(spacing: 16) { 102 | someButtonLabel() 103 | .asButton { 104 | 105 | } 106 | 107 | someButtonLabel() 108 | .asButton(.tap, action: { 109 | 110 | }) 111 | 112 | someButtonLabel() 113 | .asButton(.press, action: { 114 | 115 | }) 116 | 117 | someButtonLabel() 118 | .asButton(.opacity, action: { 119 | 120 | }) 121 | 122 | 123 | someButtonLabel() 124 | .asWebLink { 125 | URL(string: "https://www.google.com") 126 | } 127 | } 128 | .padding() 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Fonts/FontAnimatableViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontAnimateableViewModifier.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/3/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FontAnimatableViewModifier: ViewModifier { 11 | 12 | let font: Font 13 | let color: Color 14 | let lineLimit: Int? 15 | let minimumScaleFactor: CGFloat 16 | 17 | func body(content: Content) -> some View { 18 | content 19 | .font(font) 20 | .foregroundColor(Color.white) 21 | .colorMultiply(color) 22 | .lineLimit(lineLimit) 23 | .minimumScaleFactor(minimumScaleFactor) 24 | } 25 | } 26 | 27 | public extension View { 28 | 29 | /// Convenience method for adding font-related modifiers that supports animating text color. 30 | func withFontAnimatable(font: Font, color: Color, lineLimit: Int? = nil, minimumScaleFactor: CGFloat = 1) -> some View { 31 | modifier(FontAnimatableViewModifier(font: font, color: color, lineLimit: lineLimit, minimumScaleFactor: minimumScaleFactor)) 32 | } 33 | 34 | } 35 | 36 | struct FontAnimateableViewModifier_Previews: PreviewProvider { 37 | 38 | struct PreviewView: View { 39 | @State private var isActive: Bool = false 40 | var body: some View { 41 | Text("Animate color on tap!") 42 | .withFontAnimatable(font: .headline, color: isActive ? Color.red : .blue) 43 | .onTapGesture { 44 | withAnimation { 45 | isActive.toggle() 46 | } 47 | } 48 | } 49 | } 50 | 51 | static var previews: some View { 52 | PreviewView() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Fonts/FontRegularViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontRegularViewModifier.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/3/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FontRegularViewModifier: ViewModifier { 11 | 12 | let font: Font 13 | let color: Color 14 | let lineLimit: Int? 15 | let minimumScaleFactor: CGFloat 16 | 17 | func body(content: Content) -> some View { 18 | content 19 | .font(font) 20 | .foregroundColor(color) 21 | .lineLimit(lineLimit) 22 | .minimumScaleFactor(minimumScaleFactor) 23 | } 24 | } 25 | 26 | public extension View { 27 | 28 | /// Convenience method for adding font-related modifiers. 29 | func withFont(font: Font, color: Color, lineLimit: Int? = nil, minimumScaleFactor: CGFloat = 1) -> some View { 30 | modifier(FontRegularViewModifier(font: font, color: color, lineLimit: lineLimit, minimumScaleFactor: minimumScaleFactor)) 31 | } 32 | 33 | } 34 | 35 | struct FontRegularViewModifier_Previews: PreviewProvider { 36 | static var previews: some View { 37 | Text("Hello, world.") 38 | .withFont(font: .headline, color: .red, lineLimit: 1, minimumScaleFactor: 1) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/GeometryReaders/FrameReader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FrameReader.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/10/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available(iOS 14, *) 11 | /// Adds a transparent View and read it's frame. 12 | /// 13 | /// Adds a GeometryReader with infinity frame. 14 | public struct FrameReader: View { 15 | 16 | let coordinateSpace: CoordinateSpace 17 | let onChange: (_ frame: CGRect) -> Void 18 | 19 | public init(coordinateSpace: CoordinateSpace, onChange: @escaping (_ frame: CGRect) -> Void) { 20 | self.coordinateSpace = coordinateSpace 21 | self.onChange = onChange 22 | } 23 | 24 | public var body: some View { 25 | GeometryReader { geo in 26 | Text("") 27 | .frame(maxWidth: .infinity, maxHeight: .infinity) 28 | .onAppear(perform: { 29 | onChange(geo.frame(in: coordinateSpace)) 30 | }) 31 | .onChange(of: geo.frame(in: coordinateSpace), perform: onChange) 32 | } 33 | .frame(maxWidth: .infinity, maxHeight: .infinity) 34 | } 35 | } 36 | 37 | @available(iOS 14, *) 38 | public extension View { 39 | 40 | /// Get the frame of the View 41 | /// 42 | /// Adds a GeometryReader to the background of a View. 43 | func readingFrame(coordinateSpace: CoordinateSpace = .global, onChange: @escaping (_ frame: CGRect) -> ()) -> some View { 44 | background(FrameReader(coordinateSpace: coordinateSpace, onChange: onChange)) 45 | } 46 | } 47 | 48 | @available(iOS 14, *) 49 | struct FrameReader_Previews: PreviewProvider { 50 | 51 | struct PreviewView: View { 52 | 53 | @State private var yOffset: CGFloat = 0 54 | 55 | var body: some View { 56 | ScrollView(.vertical) { 57 | VStack { 58 | Text("") 59 | .frame(maxWidth: .infinity) 60 | .frame(height: 200) 61 | .cornerRadius(10) 62 | .background(Color.green) 63 | .padding() 64 | .readingFrame { frame in 65 | yOffset = frame.minY 66 | } 67 | 68 | ForEach(0..<30) { x in 69 | Text("") 70 | .frame(maxWidth: .infinity) 71 | .frame(height: 200) 72 | .cornerRadius(10) 73 | .background(Color.green) 74 | .padding() 75 | } 76 | } 77 | } 78 | .coordinateSpace(name: "test") 79 | .overlay(Text("Offset: \(yOffset)")) 80 | } 81 | } 82 | 83 | static var previews: some View { 84 | PreviewView() 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/GeometryReaders/LocationReader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationReader.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/10/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available(iOS 14, *) 11 | /// Adds a transparent View and read it's center point. 12 | /// 13 | /// Adds a GeometryReader with 0px by 0px frame. 14 | public struct LocationReader: View { 15 | 16 | let coordinateSpace: CoordinateSpace 17 | let onChange: (_ location: CGPoint) -> Void 18 | 19 | public init(coordinateSpace: CoordinateSpace, onChange: @escaping (_ location: CGPoint) -> Void) { 20 | self.coordinateSpace = coordinateSpace 21 | self.onChange = onChange 22 | } 23 | 24 | public var body: some View { 25 | FrameReader(coordinateSpace: coordinateSpace) { frame in 26 | onChange(CGPoint(x: frame.midX, y: frame.midY)) 27 | } 28 | .frame(width: 0, height: 0, alignment: .center) 29 | } 30 | } 31 | 32 | @available(iOS 14, *) 33 | public extension View { 34 | 35 | /// Get the center point of the View 36 | /// 37 | /// Adds a 0px GeometryReader to the background of a View. 38 | func readingLocation(coordinateSpace: CoordinateSpace = .global, onChange: @escaping (_ location: CGPoint) -> ()) -> some View { 39 | background(LocationReader(coordinateSpace: coordinateSpace, onChange: onChange)) 40 | } 41 | 42 | } 43 | 44 | @available(iOS 14, *) 45 | struct LocationReader_Previews: PreviewProvider { 46 | 47 | struct PreviewView: View { 48 | 49 | @State private var yOffset: CGFloat = 0 50 | 51 | var body: some View { 52 | ScrollView(.vertical) { 53 | VStack { 54 | Text("Hello, world!") 55 | .frame(maxWidth: .infinity) 56 | .frame(height: 200) 57 | .cornerRadius(10) 58 | .background(Color.green) 59 | .padding() 60 | .readingLocation { location in 61 | yOffset = location.y 62 | } 63 | 64 | ForEach(0..<30) { x in 65 | Text("") 66 | .frame(maxWidth: .infinity) 67 | .frame(height: 200) 68 | .cornerRadius(10) 69 | .background(Color.green) 70 | .padding() 71 | } 72 | } 73 | } 74 | .coordinateSpace(name: "test") 75 | .overlay(Text("Offset: \(yOffset)")) 76 | } 77 | } 78 | 79 | static var previews: some View { 80 | PreviewView() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Gestures/DragGestureViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DragGestureViewModifier.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/10/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DragGestureViewModifier: ViewModifier { 11 | 12 | @State private var offset: CGSize = .zero 13 | @State private var lastOffset: CGSize = .zero 14 | @State private var rotation: Double = 0 15 | @State private var scale: CGFloat = 1 16 | 17 | let axes: Axis.Set 18 | let minimumDistance: CGFloat 19 | let resets: Bool 20 | let animation: Animation 21 | let rotationMultiplier: CGFloat 22 | let scaleMultiplier: CGFloat 23 | let onChanged: ((_ dragOffset: CGSize) -> ())? 24 | let onEnded: ((_ dragOffset: CGSize) -> ())? 25 | 26 | init( 27 | _ axes: Axis.Set = [.horizontal, .vertical], 28 | minimumDistance: CGFloat = 0, 29 | resets: Bool, 30 | animation: Animation, 31 | rotationMultiplier: CGFloat = 0, 32 | scaleMultiplier: CGFloat = 0, 33 | onChanged: ((_ dragOffset: CGSize) -> ())?, 34 | onEnded: ((_ dragOffset: CGSize) -> ())?) { 35 | self.axes = axes 36 | self.minimumDistance = minimumDistance 37 | self.resets = resets 38 | self.animation = animation 39 | self.rotationMultiplier = rotationMultiplier 40 | self.scaleMultiplier = scaleMultiplier 41 | self.onChanged = onChanged 42 | self.onEnded = onEnded 43 | } 44 | 45 | func body(content: Content) -> some View { 46 | content 47 | .scaleEffect(scale) 48 | .rotationEffect(Angle(degrees: rotation), anchor: .center) 49 | .offset(getOffset(offset: lastOffset)) 50 | .offset(getOffset(offset: offset)) 51 | .simultaneousGesture( 52 | DragGesture(minimumDistance: minimumDistance, coordinateSpace: .global) 53 | .onChanged({ value in 54 | onChanged?(value.translation) 55 | 56 | withAnimation(animation) { 57 | offset = value.translation 58 | 59 | rotation = getRotation(translation: value.translation) 60 | scale = getScale(translation: value.translation) 61 | } 62 | }) 63 | .onEnded({ value in 64 | if !resets { 65 | onEnded?(lastOffset) 66 | } else { 67 | onEnded?(value.translation) 68 | } 69 | 70 | withAnimation(animation) { 71 | offset = .zero 72 | rotation = 0 73 | scale = 1 74 | 75 | if !resets { 76 | lastOffset = CGSize( 77 | width: lastOffset.width + value.translation.width, 78 | height: lastOffset.height + value.translation.height) 79 | } else { 80 | onChanged?(offset) 81 | } 82 | } 83 | }) 84 | ) 85 | } 86 | 87 | 88 | private func getOffset(offset: CGSize) -> CGSize { 89 | switch axes { 90 | case .vertical: 91 | return CGSize(width: 0, height: offset.height) 92 | case .horizontal: 93 | return CGSize(width: offset.width, height: 0) 94 | default: 95 | return offset 96 | } 97 | } 98 | 99 | private func getRotation(translation: CGSize) -> CGFloat { 100 | let max = UIScreen.main.bounds.width / 2 101 | let percentage = translation.width * rotationMultiplier / max 102 | let maxRotation: CGFloat = 10 103 | return percentage * maxRotation 104 | } 105 | 106 | private func getScale(translation: CGSize) -> CGFloat { 107 | let max = UIScreen.main.bounds.width / 2 108 | 109 | var offsetAmount: CGFloat = 0 110 | switch axes { 111 | case .vertical: 112 | offsetAmount = abs(translation.height + lastOffset.height) 113 | case .horizontal: 114 | offsetAmount = abs(translation.width + lastOffset.width) 115 | default: 116 | offsetAmount = (abs(translation.width + lastOffset.width) + abs(translation.height + lastOffset.height)) / 2 117 | } 118 | 119 | let percentage = offsetAmount * scaleMultiplier / max 120 | let minScale: CGFloat = 0.8 121 | let range = 1 - minScale 122 | return 1 - (range * percentage) 123 | } 124 | 125 | } 126 | 127 | public extension View { 128 | 129 | /// Add a DragGesture to a View. 130 | /// 131 | /// DragGesture is added as a simultaneousGesture, to not interfere with other gestures Developer may add. 132 | /// 133 | /// - Parameters: 134 | /// - axes: Determines the drag axes. Default allows for both horizontal and vertical movement. 135 | /// - resets: If the View should reset to starting state onEnded. 136 | /// - animation: The drag animation. 137 | /// - rotationMultiplier: Used to rotate the View while dragging. Only applies to horizontal movement. 138 | /// - scaleMultiplier: Used to scale the View while dragging. 139 | /// - onEnded: The modifier will handle the View's offset onEnded. This escaping closure is for Developer convenience. 140 | /// 141 | func withDragGesture( 142 | _ axes: Axis.Set = [.horizontal, .vertical], 143 | minimumDistance: CGFloat = 0, 144 | resets: Bool = true, 145 | animation: Animation = .spring(response: 0.3, dampingFraction: 0.8, blendDuration: 0.0), 146 | rotationMultiplier: CGFloat = 0, 147 | scaleMultiplier: CGFloat = 0, 148 | onChanged: ((_ dragOffset: CGSize) -> ())? = nil, 149 | onEnded: ((_ dragOffset: CGSize) -> ())? = nil) -> some View { 150 | modifier(DragGestureViewModifier(axes, minimumDistance: minimumDistance, resets: resets, animation: animation, rotationMultiplier: rotationMultiplier, scaleMultiplier: scaleMultiplier, onChanged: onChanged, onEnded: onEnded)) 151 | } 152 | 153 | } 154 | 155 | struct DragGestureViewModifier_Previews: PreviewProvider { 156 | 157 | static var previews: some View { 158 | RoundedRectangle(cornerRadius: 10) 159 | .frame(width: 300, height: 200) 160 | .withDragGesture( 161 | [.vertical, .horizontal], 162 | resets: true, 163 | animation: .smooth, 164 | rotationMultiplier: 1.1, 165 | scaleMultiplier: 1.1, 166 | onChanged: { dragOffset in 167 | let tx = dragOffset.height 168 | let ty = dragOffset.width 169 | }, 170 | onEnded: { dragOffset in 171 | let tx = dragOffset.height 172 | let ty = dragOffset.width 173 | } 174 | ) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Gestures/MagnificationGestureViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MagnificationGestureViewModifier.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/10/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MagnificationGestureViewModifier: ViewModifier { 11 | 12 | @State private var scale: CGFloat = 0 13 | @State private var lastScale: CGFloat = 0 14 | 15 | let resets: Bool 16 | let animation: Animation 17 | let scaleMultiplier: CGFloat 18 | let onEnded: ((_ scale: CGFloat) -> ())? 19 | 20 | init( 21 | resets: Bool, 22 | animation: Animation, 23 | scaleMultiplier: CGFloat, 24 | onEnded: ((_ scale: CGFloat) -> ())?) { 25 | self.resets = resets 26 | self.animation = animation 27 | self.scaleMultiplier = scaleMultiplier 28 | self.onEnded = onEnded 29 | } 30 | 31 | func body(content: Content) -> some View { 32 | content 33 | .scaleEffect(1 + ((scale + lastScale) * scaleMultiplier)) 34 | .simultaneousGesture( 35 | MagnificationGesture() 36 | .onChanged { value in 37 | scale = value - 1 38 | } 39 | .onEnded { value in 40 | if !resets { 41 | onEnded?(lastScale) 42 | } else { 43 | onEnded?(value - 1) 44 | } 45 | 46 | withAnimation(animation) { 47 | if resets { 48 | scale = 0 49 | } else { 50 | lastScale += scale 51 | scale = 0 52 | } 53 | } 54 | } 55 | ) 56 | } 57 | 58 | } 59 | 60 | public extension View { 61 | 62 | /// Add a MagnificationGesture to a View. 63 | /// 64 | /// MagnificationGesture is added as a simultaneousGesture, to not interfere with other gestures Developer may add. 65 | /// 66 | /// - Parameters: 67 | /// - resets: If the View should reset to starting state onEnded. 68 | /// - animation: The drag animation. 69 | /// - scaleMultiplier: Used to scale the View while dragging. 70 | /// - onEnded: The modifier will handle the View's scale onEnded. This escaping closure is for Developer convenience. 71 | /// 72 | func withMagnificationGesture( 73 | resets: Bool = true, 74 | animation: Animation = .spring(), 75 | scaleMultiplier: CGFloat = 1, 76 | onEnded: ((_ scale: CGFloat) -> ())? = nil) -> some View { 77 | modifier(MagnificationGestureViewModifier(resets: resets, animation: animation, scaleMultiplier: scaleMultiplier, onEnded: onEnded)) 78 | } 79 | 80 | } 81 | 82 | struct MagnificationGestureViewModifier_Previews: PreviewProvider { 83 | static var previews: some View { 84 | VStack { 85 | Text("Hello, world!") 86 | .padding(50) 87 | .padding(.horizontal) 88 | .background(Color.blue) 89 | .cornerRadius(10) 90 | .withMagnificationGesture() 91 | .withMagnificationGesture(resets: false, animation: .spring(), scaleMultiplier: 1.1) { scale in 92 | 93 | } 94 | } 95 | } 96 | } 97 | 98 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Gestures/RotationGestureViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RotationGestureViewModifier.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/10/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RotationGestureViewModifier: ViewModifier { 11 | 12 | @State var angle: Double = 0 13 | @State var lastAngle: Double = 0 14 | 15 | let resets: Bool 16 | let animation: Animation 17 | let onEnded: ((_ angle: Double) -> ())? 18 | 19 | init( 20 | resets: Bool, 21 | animation: Animation, 22 | onEnded: ((_ angle: Double) -> ())?) { 23 | self.resets = resets 24 | self.animation = animation 25 | self.onEnded = onEnded 26 | } 27 | 28 | func body(content: Content) -> some View { 29 | content 30 | .rotationEffect(Angle(degrees: angle + lastAngle)) 31 | .simultaneousGesture( 32 | RotationGesture() 33 | .onChanged { value in 34 | angle = value.degrees 35 | } 36 | .onEnded { value in 37 | if !resets { 38 | onEnded?(lastAngle) 39 | } else { 40 | onEnded?(value.degrees) 41 | } 42 | 43 | withAnimation(.spring()) { 44 | if resets { 45 | angle = 0 46 | } else { 47 | lastAngle += angle 48 | angle = 0 49 | } 50 | } 51 | } 52 | ) 53 | } 54 | } 55 | 56 | public extension View { 57 | 58 | /// Add a RotationGesture to a View. 59 | /// 60 | /// RotationGesture is added as a simultaneousGesture, to not interfere with other gestures Developer may add. 61 | /// 62 | /// - Parameters: 63 | /// - resets: If the View should reset to starting state onEnded. 64 | /// - animation: The drag animation. 65 | /// - onEnded: The modifier will handle the View's offset onEnded. This escaping closure is for Developer convenience. 66 | /// 67 | func withRotationGesture( 68 | resets: Bool = true, 69 | animation: Animation = .spring(), 70 | onEnded: ((_ angle: Double) -> ())? = nil) -> some View { 71 | modifier(RotationGestureViewModifier(resets: resets, animation: animation, onEnded: onEnded)) 72 | } 73 | 74 | } 75 | 76 | struct RotationGestureViewModifier_Previews: PreviewProvider { 77 | static var previews: some View { 78 | VStack { 79 | Text("Hello, world!") 80 | .padding(50) 81 | .padding(.horizontal) 82 | .background(Color.blue) 83 | .cornerRadius(10) 84 | // .withRotationGesture() 85 | .withRotationGesture(resets: false, animation: .spring()) { angle in 86 | 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Grids/NonLazyHGrid.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 1/13/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public struct NonLazyHGrid: View { 12 | var rows: Int = 2 13 | var alignment: VerticalAlignment = .center 14 | var spacing: CGFloat = 8 15 | let items: [T] 16 | @ViewBuilder var content: (T?) -> Content 17 | 18 | public init(rows: Int = 2, alignment: VerticalAlignment = .center, spacing: CGFloat = 10, items: [T], @ViewBuilder content: @escaping (T?) -> Content) { 19 | self.rows = rows 20 | self.alignment = alignment 21 | self.spacing = spacing 22 | self.items = items 23 | self.content = content 24 | } 25 | 26 | private var columnCount: Int { 27 | Int((Double(items.count) / Double(rows)).rounded(.up)) 28 | } 29 | 30 | public var body: some View { 31 | HStack(alignment: alignment, spacing: spacing, content: { 32 | ForEach(Array(0..: View { 11 | var columns: Int = 2 12 | var alignment: HorizontalAlignment = .center 13 | var spacing: CGFloat = 8 14 | let items: [T] 15 | @ViewBuilder var content: (T?) -> Content 16 | 17 | public init(columns: Int = 2, alignment: HorizontalAlignment = .center, spacing: CGFloat = 10, items: [T], @ViewBuilder content: @escaping (T?) -> Content) { 18 | self.columns = columns 19 | self.alignment = alignment 20 | self.spacing = spacing 21 | self.items = items 22 | self.content = content 23 | } 24 | 25 | private var rowCount: Int { 26 | Int((Double(items.count) / Double(columns)).rounded(.up)) 27 | } 28 | 29 | public var body: some View { 30 | VStack(alignment: alignment, spacing: spacing, content: { 31 | ForEach(Array(0..: ViewModifier { 13 | 14 | let option: HapticOption 15 | let value: Value? 16 | 17 | @ViewBuilder func body(content: Content) -> some View { 18 | if option == .never { 19 | content 20 | } else { 21 | content 22 | .onAppear(perform: { 23 | Haptics.shared.prepare(option: option) 24 | }) 25 | .onChange(of: value, perform: { _ in 26 | Haptics.shared.vibrate(option: option) 27 | }) 28 | } 29 | } 30 | 31 | } 32 | 33 | @available(iOS 14, *) 34 | public extension View { 35 | 36 | /// Add Haptic support to a View. 37 | /// 38 | /// A vibration will occur every onChangeOf the parameter. The generator will be automatically prepared when the view Appears. 39 | func withHaptic(option: HapticOption = .selection, onChangeOf value: Value?) -> some View { 40 | modifier(HapticViewModifier(option: option, value: value)) 41 | } 42 | 43 | } 44 | 45 | @available(iOS 14, *) 46 | struct HapticViewModifier_Previews: PreviewProvider { 47 | 48 | struct PreviewView: View { 49 | @State private var isOn: String = "false" 50 | var body: some View { 51 | Color.red 52 | .ignoresSafeArea() 53 | .withHaptic(onChangeOf: isOn) 54 | } 55 | } 56 | static var previews: some View { 57 | PreviewView() 58 | } 59 | } 60 | 61 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Haptics/Haptics.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Haptics.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/8/22. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | public final class Haptics { 12 | 13 | public static let shared = Haptics() 14 | private init() {} 15 | 16 | let notificationGenerator = UINotificationFeedbackGenerator() 17 | let lightGenerator = UIImpactFeedbackGenerator(style: .light) 18 | let mediumGenerator = UIImpactFeedbackGenerator(style: .medium) 19 | let heavyGenerator = UIImpactFeedbackGenerator(style: .heavy) 20 | let selectionGenerator = UISelectionFeedbackGenerator() 21 | 22 | /// Prepare the appropriate haptic generator prior to haptic occurrence. 23 | /// 24 | /// Call prepare() before the event that triggers feedback. The system needs time to prepare the Taptic Engine for minimal latency. 25 | /// Calling prepare() and then immediately triggering feedback (without any time in between) does not improve latency. 26 | /// 27 | /// - Parameter option: If providing an option, only prepare that option. If nil, prepare all options. 28 | public func prepare(option: HapticOption? = nil) { 29 | guard let option = option else { 30 | notificationGenerator.prepare() 31 | lightGenerator.prepare() 32 | mediumGenerator.prepare() 33 | heavyGenerator.prepare() 34 | return 35 | } 36 | 37 | switch option { 38 | case .success, .error, .warning: notificationGenerator.prepare() 39 | case .light: lightGenerator.prepare() 40 | case .medium: mediumGenerator.prepare() 41 | case .heavy: heavyGenerator.prepare() 42 | case .selection: selectionGenerator.prepare() 43 | case .never: break 44 | } 45 | } 46 | 47 | /// Immediately cause haptic occurrence. 48 | /// - Warning: It is recommended to call 'Haptics.prepare' prior to 'vibrate' to remove latency issues. However, vibrate will occur regardless. 49 | public func vibrate(option: HapticOption) { 50 | switch option { 51 | case .success: notificationGenerator.notificationOccurred(.success) 52 | case .error: notificationGenerator.notificationOccurred(.error) 53 | case .warning: notificationGenerator.notificationOccurred(.warning) 54 | case .light: lightGenerator.impactOccurred() 55 | case .medium: mediumGenerator.impactOccurred() 56 | case .heavy: heavyGenerator.impactOccurred() 57 | case .selection: selectionGenerator.selectionChanged() 58 | case .never: break 59 | } 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/ProgressBars/CustomProgressBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomProgressBar.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/13/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available(iOS 15, *) 11 | /// Customizable progress bar. 12 | public struct CustomProgressBar: View { 13 | 14 | let selection: T 15 | let range: ClosedRange 16 | let background: AnyShapeStyle 17 | let foreground: AnyShapeStyle 18 | let cornerRadius: CGFloat 19 | let height: CGFloat? 20 | 21 | /// Init with AnyShapeStyle (supports gradients) 22 | public init( 23 | selection: T, 24 | range: ClosedRange, 25 | background: AnyShapeStyle = AnyShapeStyle(Color.gray.opacity(0.3)), 26 | foreground: AnyShapeStyle, 27 | cornerRadius: CGFloat = 100, 28 | height: CGFloat? = 8) { 29 | self.selection = selection 30 | self.range = range 31 | self.background = background 32 | self.foreground = foreground 33 | self.cornerRadius = cornerRadius 34 | self.height = height 35 | } 36 | 37 | /// Init with plain Colors 38 | public init( 39 | selection: T, 40 | range: ClosedRange, 41 | backgroundColor: Color = Color.gray.opacity(0.3), 42 | foregroundColor: Color = .blue, 43 | cornerRadius: CGFloat = 100, 44 | height: CGFloat? = 8) { 45 | self.selection = selection 46 | self.range = range 47 | self.background = AnyShapeStyle(backgroundColor) 48 | self.foreground = AnyShapeStyle(foregroundColor) 49 | self.cornerRadius = cornerRadius 50 | self.height = height 51 | } 52 | 53 | public var body: some View { 54 | GeometryReader { geo in 55 | ZStack(alignment: .leading) { 56 | RoundedRectangle(cornerRadius: cornerRadius) 57 | .fill(background) 58 | 59 | RoundedRectangle(cornerRadius: cornerRadius) 60 | .fill(foreground) 61 | .frame(width: getCurrentProgress(geo: geo)) 62 | } 63 | } 64 | .frame(height: height) 65 | } 66 | 67 | private func getCurrentProgress(geo: GeometryProxy) -> CGFloat { 68 | /* 69 | Progress = ((Selection - Min) / (Max - Min)) * width 70 | 71 | Ex. 72 | If selection = 15 and range = 10...20 73 | Progress = (15 - 10) / (20 - 10) * width 74 | Progress = 5 / 10 * width 75 | Progress = 50% * width 76 | 77 | Ex. 78 | If selection = 25 and range = 0...100 79 | Progress = (25 - 0) / (100 - 0) * width 80 | Progress = 25 / 100 * width 81 | Progress 25% * width 82 | */ 83 | 84 | let minRange = max(range.lowerBound, 0) 85 | let maxRange = max(range.upperBound, 1) 86 | 87 | // Ensure progress is within range 88 | var safeSelection = min(selection, maxRange) 89 | safeSelection = max(safeSelection, minRange) 90 | 91 | let percent = (safeSelection - minRange) / (maxRange - minRange) 92 | return CGFloat(percent) * geo.size.width 93 | } 94 | } 95 | 96 | @available(iOS 15, *) 97 | struct CustomProgressBar_Previews: PreviewProvider { 98 | 99 | struct PreviewView: View { 100 | @State private var selection: Double = 55 101 | @State private var range: ClosedRange = 0...100 102 | 103 | var body: some View { 104 | CustomProgressBar(selection: selection, range: range) 105 | } 106 | } 107 | 108 | static var previews: some View { 109 | PreviewView() 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Redacted/RedactedViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RedactedViewModifier.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 3/30/22. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | @available(iOS 14, *) 12 | struct RedactedViewModifier: ViewModifier { 13 | 14 | let isRedacted: Bool 15 | let style: RedactedStyle 16 | @State private var showContent: Bool = false 17 | 18 | @ViewBuilder func body(content: Content) -> some View { 19 | switch style { 20 | case .placeholder: 21 | content 22 | .redacted(reason: isRedacted ? .placeholder : []) 23 | case .opacity: 24 | content 25 | .opacity(showContent ? 1 : 0) 26 | .animation(.easeInOut, value: showContent) 27 | .onChange(of: isRedacted) { newValue in 28 | showContent = !newValue 29 | } 30 | case .appear: 31 | content 32 | .opacity(isRedacted ? 0 : 1) 33 | case .never: 34 | content 35 | } 36 | } 37 | 38 | } 39 | 40 | public enum RedactedStyle { 41 | case placeholder 42 | case opacity 43 | case appear 44 | case never 45 | } 46 | 47 | @available(iOS 14, *) 48 | public extension View { 49 | 50 | /// Redact any View based on a Boolean value. 51 | /// 52 | /// ``` 53 | /// Image(uiImage: image) 54 | /// .redacted(if: image == nil, style: .placeholder) 55 | /// ``` 56 | func redacted(if isRedacted: Bool, style: RedactedStyle) -> some View { 57 | modifier(RedactedViewModifier(isRedacted: isRedacted, style: style)) 58 | } 59 | 60 | } 61 | 62 | @available(iOS 14, *) 63 | struct RedactedView_Previews: PreviewProvider { 64 | 65 | struct RedactedView: View { 66 | 67 | @State private var isRedacted: Bool = true 68 | 69 | var body: some View { 70 | Image(systemName: "heart.fill") 71 | .resizable() 72 | .scaledToFit() 73 | .frame(width: 200, height: 200) 74 | .redacted(if: isRedacted, style: .placeholder) 75 | .onAppear { 76 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 77 | isRedacted = false 78 | } 79 | } 80 | } 81 | } 82 | 83 | static var previews: some View { 84 | RedactedView() 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/ScrollViews/ScrollViewWithOnScrollChanged.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollViewWithOnScrollChanged.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/8/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available(iOS 14, *) 11 | public struct ScrollViewWithOnScrollChanged: View { 12 | 13 | let axes: Axis.Set 14 | let showsIndicators: Bool 15 | let content: Content 16 | let onScrollChanged: (_ origin: CGPoint) -> () 17 | @State private var coordinateSpaceID: String = UUID().uuidString 18 | 19 | public init( 20 | _ axes: Axis.Set = .vertical, 21 | showsIndicators: Bool = false, 22 | @ViewBuilder content: () -> Content, 23 | onScrollChanged: @escaping (_ origin: CGPoint) -> ()) { 24 | self.axes = axes 25 | self.showsIndicators = showsIndicators 26 | self.content = content() 27 | self.onScrollChanged = onScrollChanged 28 | } 29 | 30 | public var body: some View { 31 | ScrollView(axes, showsIndicators: showsIndicators) { 32 | LocationReader(coordinateSpace: .named(coordinateSpaceID), onChange: onScrollChanged) 33 | content 34 | } 35 | .coordinateSpace(name: coordinateSpaceID) 36 | } 37 | } 38 | 39 | @available(iOS 14, *) 40 | struct ScrollViewWithOnScrollChanged_Previews: PreviewProvider { 41 | 42 | struct PreviewView: View { 43 | 44 | @State private var yPosition: CGFloat = 0 45 | 46 | var body: some View { 47 | ScrollViewWithOnScrollChanged { 48 | VStack { 49 | ForEach(0..<30) { x in 50 | Text("x: \(x)") 51 | .frame(maxWidth: .infinity) 52 | .frame(height: 200) 53 | .cornerRadius(10) 54 | .background(Color.red) 55 | .padding() 56 | .id(x) 57 | } 58 | } 59 | } onScrollChanged: { origin in 60 | yPosition = origin.y 61 | } 62 | .overlay(Text("Offset: \(yPosition)")) 63 | } 64 | } 65 | 66 | static var previews: some View { 67 | PreviewView() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/ScrollViews/ScrollViewWithScrollToLocation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollViewWithScrollToLocation.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/8/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available(iOS 14, *) 11 | public struct ScrollViewWithScrollToLocation: View { 12 | 13 | let axes: Axis.Set 14 | let showsIndicators: Bool 15 | let content: Content 16 | let scrollToLocation: AnyHashable? 17 | let scrollAnchor: UnitPoint 18 | let animated: Bool 19 | 20 | public init( 21 | _ axes: Axis.Set = .vertical, 22 | showsIndicators: Bool = false, 23 | scrollToLocation: AnyHashable?, 24 | scrollAnchor: UnitPoint = .center, 25 | animated: Bool = true, 26 | @ViewBuilder content: () -> Content) { 27 | self.axes = axes 28 | self.showsIndicators = showsIndicators 29 | self.content = content() 30 | self.scrollToLocation = scrollToLocation 31 | self.scrollAnchor = scrollAnchor 32 | self.animated = animated 33 | } 34 | 35 | public var body: some View { 36 | ScrollView(axes, showsIndicators: showsIndicators) { 37 | ScrollViewReader { proxy in 38 | content 39 | .onChange(of: scrollToLocation) { newValue in 40 | scroll(to: newValue, proxy: proxy) 41 | } 42 | } 43 | } 44 | } 45 | 46 | private func scroll(to location: AnyHashable?, proxy: ScrollViewProxy) { 47 | guard let location = location else { return } 48 | withAnimation(animated ? .default : .none) { 49 | proxy.scrollTo(location, anchor: scrollAnchor) 50 | } 51 | } 52 | } 53 | 54 | @available(iOS 14, *) 55 | struct ScrollViewWithScrollToLocation_Previews: PreviewProvider { 56 | 57 | struct PreviewView: View { 58 | 59 | @State private var scrollToLocation: Int = 0 60 | 61 | var body: some View { 62 | ScrollViewWithScrollToLocation(scrollToLocation: scrollToLocation) { 63 | VStack { 64 | ForEach(0..<30) { x in 65 | Text("x: \(x)") 66 | .frame(maxWidth: .infinity) 67 | .frame(height: 200) 68 | .cornerRadius(10) 69 | .background(Color.red) 70 | .padding() 71 | .id(x) 72 | } 73 | } 74 | .onAppear { 75 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 76 | scrollToLocation = 16 77 | } 78 | DispatchQueue.main.asyncAfter(deadline: .now() + 4) { 79 | scrollToLocation = 2 80 | } 81 | DispatchQueue.main.asyncAfter(deadline: .now() + 6) { 82 | scrollToLocation = 29 83 | } 84 | DispatchQueue.main.asyncAfter(deadline: .now() + 8) { 85 | scrollToLocation = 0 86 | } 87 | } 88 | } 89 | .overlay(Text("Scrolling to: \(scrollToLocation)")) 90 | } 91 | } 92 | 93 | static var previews: some View { 94 | PreviewView() 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Stacks/LazyZStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LazyZStack.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 3/1/24. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftfulRecursiveUI 10 | 11 | public typealias LazyZStack = SwiftfulRecursiveUI.AnyRecursiveView 12 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/TabBars/TabBarDefaultView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabBarDefaultView.swift 3 | // Catalog 4 | // 5 | // Created by Nick Sarno on 11/14/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | /// Customizable TabBar 12 | /// 13 | /// ```swift 14 | /// // 'Default' style 15 | /// TabBarDefaultView( 16 | /// tabs: tabs, 17 | /// selection: $selection, 18 | /// accentColor: .blue, 19 | /// defaultColor: .gray, 20 | /// backgroundColor: .white, 21 | /// font: .caption, 22 | /// iconSize: 20, 23 | /// spacing: 6, 24 | /// insetPadding: 10, 25 | /// outerPadding: 0, 26 | /// cornerRadius: 0) 27 | /// 28 | /// // 'Floating' style 29 | /// TabBarDefaultView( 30 | /// tabs: tabs, 31 | /// selection: $selection, 32 | /// accentColor: .blue, 33 | /// defaultColor: .gray, 34 | /// backgroundColor: .white, 35 | /// font: .caption, 36 | /// iconSize: 20, 37 | /// spacing: 6, 38 | /// insetPadding: 12, 39 | /// outerPadding: 12, 40 | /// cornerRadius: 30, 41 | /// shadowRadius: 8) 42 | /// ``` 43 | public struct TabBarDefaultView: View { 44 | 45 | let tabs: [TabBarItem] 46 | @Binding var selection: TabBarItem 47 | let accentColor: Color 48 | let defaultColor: Color 49 | let backgroundColor: Color? 50 | let font: Font 51 | let iconSize: CGFloat 52 | let spacing: CGFloat 53 | let insetPadding: CGFloat 54 | let outerPadding: CGFloat 55 | let cornerRadius: CGFloat 56 | let shadowRadius: CGFloat 57 | 58 | public init( 59 | tabs: [TabBarItem], 60 | selection: Binding, 61 | accentColor: Color = .blue, 62 | defaultColor: Color = .gray, 63 | backgroundColor: Color? = nil, 64 | font: Font = .caption, 65 | iconSize: CGFloat = 20, 66 | spacing: CGFloat = 4, 67 | insetPadding: CGFloat = 10, 68 | outerPadding: CGFloat = 0, 69 | cornerRadius: CGFloat = 0, 70 | shadowRadius: CGFloat = 0 71 | ) { 72 | self._selection = selection 73 | self.tabs = tabs 74 | self.accentColor = accentColor 75 | self.defaultColor = defaultColor 76 | self.backgroundColor = backgroundColor 77 | self.font = font 78 | self.iconSize = iconSize 79 | self.spacing = spacing 80 | self.insetPadding = insetPadding 81 | self.outerPadding = outerPadding 82 | self.cornerRadius = cornerRadius 83 | self.shadowRadius = shadowRadius 84 | } 85 | 86 | public var body: some View { 87 | HStack(spacing: 0) { 88 | ForEach(tabs, id: \.self) { tab in 89 | tabView(tab) 90 | .background(Color.black.opacity(0.001)) 91 | .onTapGesture { 92 | switchToTab(tab: tab) 93 | } 94 | } 95 | } 96 | .padding(.horizontal, insetPadding) 97 | .background( 98 | ZStack { 99 | if let backgroundColor = backgroundColor { 100 | backgroundColor 101 | .shadow(radius: shadowRadius) 102 | .edgesIgnoringSafeArea(.all) 103 | } else { 104 | Color.clear 105 | } 106 | } 107 | ) 108 | .cornerRadiusIfNeeded(cornerRadius: cornerRadius) 109 | .padding(outerPadding) 110 | } 111 | 112 | private func switchToTab(tab: TabBarItem) { 113 | selection = tab 114 | } 115 | 116 | } 117 | 118 | extension View { 119 | 120 | @ViewBuilder func cornerRadiusIfNeeded(cornerRadius: CGFloat) -> some View { 121 | if cornerRadius > 0 { 122 | self 123 | .cornerRadius(cornerRadius) 124 | } else { 125 | self 126 | } 127 | } 128 | 129 | } 130 | 131 | struct TabBarDefaultView_Previews: PreviewProvider { 132 | static var previews: some View { 133 | TabBarViewBuilder_Previews.previews 134 | } 135 | } 136 | 137 | private extension TabBarDefaultView { 138 | 139 | private func tabView(_ tab: TabBarItem) -> some View { 140 | VStack(spacing: spacing) { 141 | if let icon = tab.iconName { 142 | Image(systemName: icon) 143 | .resizable() 144 | .scaledToFit() 145 | .frame(width: iconSize, height: iconSize) 146 | } 147 | if let image = tab.image { 148 | Image(uiImage: image) 149 | .resizable() 150 | .scaledToFit() 151 | .frame(width: iconSize, height: iconSize) 152 | } 153 | if let title = tab.title { 154 | Text(title) 155 | } 156 | } 157 | .font(font) 158 | .foregroundColor(selection == tab ? accentColor : defaultColor) 159 | .frame(maxWidth: .infinity) 160 | .padding(.vertical, insetPadding) 161 | .overlay( 162 | ZStack { 163 | if let count = tab.badgeCount, count > 0 { 164 | Text("\(count)") 165 | .foregroundColor(.white) 166 | .font(.caption) 167 | .padding(6) 168 | .background(accentColor) 169 | .clipShape(Circle()) 170 | .offset(x: iconSize * 0.9, y: -iconSize * 0.9) 171 | } 172 | } 173 | ) 174 | } 175 | 176 | } 177 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/TabBars/TabBarItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabBarItem.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/8/22. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | public struct TabBarItem: Hashable { 12 | public let title: String? 13 | public let iconName: String? 14 | public let image: UIImage? 15 | public private(set) var badgeCount: Int? 16 | 17 | public init(title: String?, iconName: String? = nil, image: UIImage? = nil, badgeCount: Int? = nil) { 18 | self.title = title 19 | self.iconName = iconName 20 | self.image = image 21 | self.badgeCount = badgeCount 22 | } 23 | 24 | public mutating func updateBadgeCount(to count: Int) { 25 | badgeCount = count 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/TabBars/TabBarItemViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabBarItemViewModifier.swift 3 | // Catalog 4 | // 5 | // Created by Nick Sarno on 11/14/21. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct TabBarItemsPreferenceKey: PreferenceKey { 12 | static var defaultValue: [AnyHashable] = [] 13 | 14 | static func reduce(value: inout [AnyHashable], nextValue: () -> [AnyHashable]) { 15 | value += nextValue() 16 | } 17 | } 18 | 19 | struct TabBarItemViewModifer: ViewModifier { 20 | 21 | @State private var didLoad: Bool = false 22 | let tab: AnyHashable 23 | let selection: AnyHashable 24 | 25 | func body(content: Content) -> some View { 26 | ZStack { 27 | if didLoad || selection == tab { 28 | content 29 | .preference(key: TabBarItemsPreferenceKey.self, value: [tab]) 30 | .opacity(selection == tab ? 1 : 0) 31 | .onAppear { 32 | didLoad = true 33 | } 34 | } 35 | } 36 | } 37 | 38 | } 39 | 40 | public extension View { 41 | 42 | /// Tag a View with a value. Use selection to determine which tab is currently displaying. 43 | func tabBarItem(tab: AnyHashable, selection: AnyHashable) -> some View { 44 | modifier(TabBarItemViewModifer(tab: tab, selection: selection)) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/TabBars/TabBarViewBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabBarViewBuilder.swift 3 | // Catalog 4 | // 5 | // Created by Nick Sarno on 11/13/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Custom tab bar with lazy loading. 11 | /// 12 | /// Tabs are loaded lazily, as they are selected. Each tab's .onAppear will only be called on first appearance. Set DisplayStyle to .vStack to position TabBar vertically below the Content. Use .zStack to put the TabBar in front of the Content . 13 | public struct TabBarViewBuilder: View { 14 | 15 | public enum DisplayStyle { 16 | case vStack 17 | case zStack 18 | } 19 | 20 | let style: DisplayStyle 21 | let content: Content 22 | let tabBar: TabBar 23 | 24 | public init( 25 | style: DisplayStyle = .vStack, 26 | @ViewBuilder content: () -> Content, 27 | @ViewBuilder tabBar: () -> TabBar) { 28 | self.style = style 29 | self.content = content() 30 | self.tabBar = tabBar() 31 | } 32 | 33 | public var body: some View { 34 | layout 35 | } 36 | 37 | @ViewBuilder var layout: some View { 38 | switch style { 39 | case .vStack: 40 | VStack(spacing: 0) { 41 | ZStack { 42 | content 43 | } 44 | .frame(maxWidth: .infinity, maxHeight: .infinity) 45 | 46 | tabBar 47 | } 48 | case .zStack: 49 | ZStack(alignment: .bottom) { 50 | content 51 | tabBar 52 | } 53 | } 54 | } 55 | } 56 | 57 | struct TabBarViewBuilder_Previews: PreviewProvider { 58 | 59 | struct PreviewView: View { 60 | @State var selection: TabBarItem = TabBarItem(title: "Home", iconName: "heart.fill") 61 | @State private var tabs: [TabBarItem] = [ 62 | TabBarItem(title: "Home", iconName: "heart.fill", badgeCount: 2), 63 | TabBarItem(title: "Browse", iconName: "magnifyingglass"), 64 | TabBarItem(title: "Discover", iconName: "globe", badgeCount: 100), 65 | TabBarItem(title: "Profile", iconName: "person.fill") 66 | ] 67 | 68 | var body: some View { 69 | TabBarViewBuilder { 70 | RoundedRectangle(cornerRadius: 0) 71 | .fill(Color.blue) 72 | .tabBarItem(tab: tabs[0], selection: selection) 73 | .edgesIgnoringSafeArea(.all) 74 | 75 | RoundedRectangle(cornerRadius: 0) 76 | .fill(Color.red) 77 | .onAppear { 78 | tabs[0].updateBadgeCount(to: 0) 79 | } 80 | .tabBarItem(tab: tabs[1], selection: selection) 81 | .edgesIgnoringSafeArea(.all) 82 | } tabBar: { 83 | TabBarDefaultView(tabs: tabs, selection: $selection) 84 | } 85 | } 86 | } 87 | 88 | static var previews: some View { 89 | PreviewView() 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Toggles/CustomToggle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomToggle.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 4/13/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available(iOS 14, *) 11 | /// Customizable Toggle. 12 | public struct CustomToggle: View { 13 | 14 | @Binding var isOn: Bool 15 | let width: CGFloat 16 | let pinColor: Color 17 | let backgroundColor: Color 18 | let haptic: HapticOption 19 | 20 | public init( 21 | isOn: Binding, 22 | width: CGFloat = 64, 23 | pinColor: Color = .blue, 24 | backgroundColor: Color = Color.gray.opacity(0.3), 25 | haptic: HapticOption = .never) { 26 | self._isOn = isOn 27 | self.width = width 28 | self.pinColor = pinColor 29 | self.backgroundColor = backgroundColor 30 | self.haptic = haptic 31 | } 32 | 33 | public var body: some View { 34 | Circle() 35 | .fill(pinColor) 36 | .aspectRatio(1, contentMode: .fit) 37 | .padding(width * 0.06) 38 | .frame(width: width, height: width / 1.8, alignment: isOn ? .trailing : .leading) 39 | .withBackground(color: backgroundColor, cornerRadius: 100) 40 | .animation(.spring(), value: isOn) 41 | .withHaptic(option: haptic, onChangeOf: isOn) 42 | .onTapGesture { 43 | isOn.toggle() 44 | } 45 | } 46 | } 47 | 48 | @available(iOS 14, *) 49 | struct CustomToggle_Previews: PreviewProvider { 50 | 51 | struct PreviewView: View { 52 | @State private var isOn: Bool = false 53 | var body: some View { 54 | VStack { 55 | CustomToggle(isOn: $isOn) 56 | CustomToggle(isOn: $isOn, width: 100, pinColor: .gray, backgroundColor: .black, haptic: .selection) 57 | CustomToggle(isOn: $isOn, pinColor: .white, backgroundColor: isOn ? .green : .gray.opacity(0.3)) 58 | CustomToggle(isOn: $isOn, pinColor: isOn ? .red : .white, backgroundColor: isOn ? .blue : .gray.opacity(0.3)) 59 | } 60 | } 61 | } 62 | 63 | static var previews: some View { 64 | PreviewView() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/ViewModifiers/AnyNotificationListenerViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyNotificationListenerViewModifier.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 11/11/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct AnyNotificationListenerViewModifier: ViewModifier { 12 | 13 | let notificationName: Notification.Name 14 | let onNotificationRecieved: @MainActor (Notification) -> Void 15 | 16 | func body(content: Content) -> some View { 17 | content 18 | .onReceive(NotificationCenter.default.publisher(for: notificationName), perform: { notification in 19 | onNotificationRecieved(notification) 20 | }) 21 | } 22 | } 23 | 24 | extension View { 25 | 26 | public func onNotificationRecieved(name: Notification.Name, action: @MainActor @escaping (Notification) -> Void) -> some View { 27 | modifier(AnyNotificationListenerViewModifier(notificationName: name, onNotificationRecieved: action)) 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/ViewModifiers/OnFirstAppearViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnFirstAppearViewModifier.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 11/11/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct OnFirstAppearViewModifier: ViewModifier { 12 | 13 | @State private var didAppear: Bool = false 14 | let action: @MainActor () -> Void 15 | 16 | func body(content: Content) -> some View { 17 | content 18 | .onAppear { 19 | guard !didAppear else { return } 20 | didAppear = true 21 | action() 22 | } 23 | } 24 | } 25 | 26 | extension View { 27 | 28 | public func onFirstAppear(perform action: @MainActor @escaping () -> Void) -> some View { 29 | modifier(OnFirstAppearViewModifier(action: action)) 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/ViewModifiers/OnFirstDisappearViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnFirstDisappearViewModifier.swift 3 | // 4 | // 5 | // Created by Ricky Stone on 12/11/2023. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct OnFirstDisappearViewModifier: ViewModifier { 12 | 13 | @State private var didDisappear: Bool = false 14 | let action: @MainActor () -> Void 15 | 16 | func body(content: Content) -> some View { 17 | content 18 | .onDisappear { 19 | guard !didDisappear else { return } 20 | didDisappear = true 21 | action() 22 | } 23 | } 24 | } 25 | 26 | extension View { 27 | public func onFirstDisappear(perform action: @MainActor @escaping () -> Void) -> some View { 28 | modifier(OnFirstDisappearViewModifier(action: action)) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/ViewModifiers/StetchyHeaderViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 2/10/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct StetchyHeaderViewModifier: ViewModifier { 12 | 13 | var startingHeight: CGFloat = 300 14 | var coordinateSpace: CoordinateSpace = .global 15 | 16 | func body(content: Content) -> some View { 17 | GeometryReader(content: { geometry in 18 | content 19 | .frame(width: geometry.size.width, height: stretchedHeight(geometry)) 20 | .clipped() 21 | .offset(y: stretchedOffset(geometry)) 22 | }) 23 | .frame(height: startingHeight) 24 | } 25 | 26 | private func yOffset(_ geo: GeometryProxy) -> CGFloat { 27 | geo.frame(in: coordinateSpace).minY 28 | } 29 | 30 | private func stretchedHeight(_ geo: GeometryProxy) -> CGFloat { 31 | let offset = yOffset(geo) 32 | return offset > 0 ? (startingHeight + offset) : startingHeight 33 | } 34 | 35 | private func stretchedOffset(_ geo: GeometryProxy) -> CGFloat { 36 | let offset = yOffset(geo) 37 | return offset > 0 ? -offset : 0 38 | } 39 | } 40 | 41 | public extension View { 42 | 43 | func asStretchyHeader(startingHeight: CGFloat) -> some View { 44 | modifier(StetchyHeaderViewModifier(startingHeight: startingHeight)) 45 | } 46 | } 47 | 48 | #Preview { 49 | ZStack { 50 | Color.black.edgesIgnoringSafeArea(.all) 51 | 52 | ScrollView { 53 | VStack { 54 | Rectangle() 55 | .fill(Color.green) 56 | .overlay( 57 | ZStack { 58 | if #available(iOS 15.0, *) { 59 | AsyncImage(url: URL(string: "https://picsum.photos/800/800")) 60 | } 61 | } 62 | // Image(systemName: "heart.fill") 63 | // .resizable() 64 | // .scaledToFill() 65 | // .padding(100) 66 | ) 67 | .asStretchyHeader(startingHeight: 300) 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Views/CountdownViewBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 12/9/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available(iOS 14, *) 11 | public struct CountdownViewBuilder: View { 12 | 13 | let endTime: Date 14 | var displayOption: Double.HoursMinutesSecondsDisplayOption = .timeAs_00_00_00 15 | let content: (String) -> Content 16 | var onTimerEnded: (@MainActor () -> Void)? = nil 17 | let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() 18 | 19 | @State private var timeRemaining: Double = 0 20 | 21 | public init(endTime: Date, displayOption: Double.HoursMinutesSecondsDisplayOption, content: @escaping (String) -> Content, onTimerEnded: ( () -> Void)? = nil) { 22 | self.endTime = endTime 23 | self.displayOption = displayOption 24 | self.content = content 25 | self.onTimerEnded = onTimerEnded 26 | } 27 | 28 | public var body: some View { 29 | content(timeRemaining.asHoursMinutesSeconds(display: displayOption)) 30 | .onReceive(timer) { _ in 31 | if self.timeRemaining > 0 { 32 | self.timeRemaining -= 1 33 | } else { 34 | self.timer.upstream.connect().cancel() 35 | self.onTimerEnded?() 36 | } 37 | } 38 | .onChange(of: endTime, perform: { newValue in 39 | timeRemaining = newValue.timeIntervalSince(Date()) 40 | }) 41 | .onAppear { 42 | timeRemaining = endTime.timeIntervalSince(Date()) 43 | } 44 | .onNotificationRecieved(name: UIApplication.didBecomeActiveNotification, action: { _ in 45 | timeRemaining = endTime.timeIntervalSince(Date()) 46 | }) 47 | } 48 | } 49 | 50 | @available(iOS 14, *) 51 | struct CountdownViewBuilder_Previews: PreviewProvider { 52 | 53 | static let endTime = Date().addingTimeInterval(60 * 60 * 2) 54 | static let endTime2 = Date().addingTimeInterval(60 * 60 * 24 * 7) 55 | 56 | static var previews: some View { 57 | VStack(spacing: 20) { 58 | CountdownViewBuilder( 59 | endTime: endTime, 60 | displayOption: .timeAs_00_00_00, 61 | content: { string in 62 | Text(string) 63 | }, 64 | onTimerEnded: { 65 | 66 | } 67 | ) 68 | 69 | CountdownViewBuilder( 70 | endTime: endTime, 71 | displayOption: .timeAs_h_m_s, 72 | content: { string in 73 | Text(string) 74 | }, 75 | onTimerEnded: { 76 | 77 | } 78 | ) 79 | 80 | CountdownViewBuilder( 81 | endTime: endTime2, 82 | displayOption: .timeAs_d_h_m, 83 | content: { string in 84 | Text(string) 85 | }, 86 | onTimerEnded: { 87 | 88 | } 89 | ) 90 | } 91 | } 92 | } 93 | 94 | extension Double { 95 | 96 | public enum HoursMinutesSecondsDisplayOption { 97 | case timeAs_00_00_00, timeAs_h_m_s, timeAs_d_h_m 98 | 99 | public func stringValue(of value: Double) -> String { 100 | switch self { 101 | case .timeAs_00_00_00: 102 | return value.asHoursMinutesSeconds_as_00_00_00 103 | case .timeAs_h_m_s: 104 | return value.asHoursMinutesSeconds_as_h_m_s 105 | case .timeAs_d_h_m: 106 | return value.asDaysHoursMinutes 107 | } 108 | } 109 | } 110 | 111 | public func asHoursMinutesSeconds(display: HoursMinutesSecondsDisplayOption) -> String { 112 | display.stringValue(of: self) 113 | } 114 | 115 | public var asHoursMinutesSeconds_as_00_00_00: String { 116 | let hours = Int(self) / 3600 117 | let minutes = Int(self) / 60 % 60 118 | let seconds = Int(self) % 60 119 | return String(format:"%02i:%02i:%02i", hours, minutes, seconds) 120 | } 121 | 122 | public var asHoursMinutesSeconds_as_h_m_s: String { 123 | let timeInterval = TimeInterval(self) 124 | let date = Date(timeIntervalSinceNow: timeInterval) 125 | 126 | let components = Calendar.current.dateComponents([.hour, .minute, .second], from: Date(), to: date) 127 | 128 | var timeString = "" 129 | 130 | if let hours = components.hour, hours > 0 { 131 | timeString += "\(hours)h " 132 | } 133 | 134 | if let minutes = components.minute, (minutes > 0 || (components.hour ?? 0) > 0) { 135 | timeString += "\(minutes)m " 136 | } 137 | 138 | if let seconds = components.second { 139 | timeString += "\(seconds)s" 140 | } 141 | 142 | return timeString.isEmpty ? "0s" : timeString 143 | } 144 | 145 | public var asDaysHoursMinutes: String { 146 | let days = Int(self) / 86400 147 | let hours = (Int(self) % 86400) / 3600 148 | let minutes = (Int(self) % 3600) / 60 149 | 150 | var timeString = "" 151 | 152 | if days > 0 { 153 | timeString += "\(days)d " 154 | } 155 | 156 | if hours > 0 || days > 0 { 157 | timeString += "\(hours)h " 158 | } 159 | 160 | if minutes > 0 || hours > 0 || days > 0 { 161 | timeString += "\(minutes)m" 162 | } 163 | 164 | return timeString.isEmpty ? "0m" : timeString 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Views/FlipView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlipView.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 2/13/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct FlipView: View { 11 | 12 | @Binding private var isFlipped: Bool 13 | @ViewBuilder private var frontView: FrontView 14 | @ViewBuilder private var backView: BackView 15 | let animation: Animation 16 | 17 | /// - Parameters: 18 | /// - isFlipped: 19 | /// - animation: ANIMATION WORKS BEST WITH SYMMETRICAL TIMING (ie. .linear, .easeInOut, etc) 20 | /// - frontView: 21 | /// - backView: 22 | public init( 23 | isFlipped: Binding, 24 | animation: Animation = .easeInOut(duration: 0.2), 25 | @ViewBuilder frontView: () -> FrontView, 26 | @ViewBuilder backView: () -> BackView 27 | ) { 28 | self._isFlipped = isFlipped 29 | self.animation = animation 30 | self.frontView = frontView() 31 | self.backView = backView() 32 | } 33 | 34 | public var body: some View { 35 | ZStack { 36 | frontView 37 | // .background( 38 | // RoundedRectangle(cornerRadius: 32) 39 | // .shadow(color: Color.black.opacity(0.125), radius: 4, x: 0, y: 4) 40 | // ) 41 | .modifier(FlipOpacity(percentage: isFlipped ? 0 : 1)) 42 | .rotation3DEffect(.degrees(isFlipped ? 180 : 360), axis: (0,1,0)) 43 | 44 | backView 45 | // .background( 46 | // RoundedRectangle(cornerRadius: 32) 47 | // .shadow(color: Color.black.opacity(0.125), radius: 4, x: 0, y: 4) 48 | // ) 49 | .modifier(FlipOpacity(percentage: isFlipped ? 1 : 0)) 50 | .rotation3DEffect(.degrees(isFlipped ? 0 : 180), axis: (0,1,0)) 51 | } 52 | .animation(animation, value: isFlipped) 53 | } 54 | } 55 | 56 | fileprivate struct FlipOpacity: AnimatableModifier { 57 | 58 | var percentage: CGFloat = 0 59 | 60 | var animatableData: CGFloat { 61 | get { 62 | percentage 63 | } 64 | set { 65 | percentage = newValue 66 | } 67 | } 68 | 69 | func body(content: Content) -> some View { 70 | content 71 | .opacity(Double(percentage.rounded())) 72 | } 73 | 74 | } 75 | 76 | fileprivate struct FlipPreview: View { 77 | 78 | @State private var isFlipped: Bool = false 79 | 80 | var body: some View { 81 | FlipView( 82 | isFlipped: $isFlipped, 83 | animation: .easeInOut(duration: 0.5), 84 | frontView: { 85 | Rectangle() 86 | .fill(Color.red) 87 | .cornerRadius(32) 88 | .padding(.vertical, 40) 89 | }, 90 | backView: { 91 | Rectangle() 92 | .fill(Color.blue) 93 | .cornerRadius(32) 94 | .padding(.vertical, 40) 95 | } 96 | ) 97 | .onTapGesture { 98 | isFlipped.toggle() 99 | } 100 | } 101 | } 102 | 103 | #Preview { 104 | FlipPreview() 105 | .padding(40) 106 | } 107 | -------------------------------------------------------------------------------- /Sources/SwiftfulUI/Views/RootView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RootView.swift 3 | // 4 | // 5 | // Created by Nick Sarno on 11/11/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// App delegate life cycle actions for SwiftUI. 11 | /// 12 | /// ### onApplicationDidAppear: 13 | /// When the app first appears. This is called when the SwiftUI View appears, which is after didFinishLaunchingWithOptions. There is no didFinishLaunchingWithOptions because that is triggered before SwiftUI renders. 14 | /// 15 | /// 16 | /// ### onApplicationWillEnterForeground: 17 | /// When the app transitions to active state. When the app will reactivate, this gets called immediately before applicationDidBecomeActive. 18 | /// 19 | /// 20 | /// ### onApplicationDidBecomeActive: 21 | /// When the app returns to active state after being in an inactive state. 22 | /// 23 | /// 24 | /// ### onApplicationWillResignActive: 25 | /// When the app transitions away from active state. Each time a temporary event, such as a phone call, happens this method gets called. 26 | /// 27 | /// 28 | /// ### onApplicationDidEnterBackground: 29 | /// When the app enters the background but is still running. If the user is terminating the app, this is called immediately before applicationWillTerminate. The app will have approximately five seconds to perform tasks before the application terminates. 30 | /// 31 | /// 32 | /// ### onApplicationWillTerminate: 33 | /// When the app terminates. Events such as force quitting the iOS app or shutting down the device. 34 | public struct RootDelegate { 35 | 36 | var onApplicationDidAppear: (() -> Void)? = nil 37 | var onApplicationWillEnterForeground: ((Notification) -> Void)? = nil 38 | var onApplicationDidBecomeActive: ((Notification) -> Void)? = nil 39 | var onApplicationWillResignActive: ((Notification) -> Void)? = nil 40 | var onApplicationDidEnterBackground: ((Notification) -> Void)? = nil 41 | var onApplicationWillTerminate: ((Notification) -> Void)? = nil 42 | 43 | public init( 44 | onApplicationDidAppear: (() -> Void)? = nil, 45 | onApplicationWillEnterForeground: ((Notification) -> Void)? = nil, 46 | onApplicationDidBecomeActive: ((Notification) -> Void)? = nil, 47 | onApplicationWillResignActive: ((Notification) -> Void)? = nil, 48 | onApplicationDidEnterBackground: ((Notification) -> Void)? = nil, 49 | onApplicationWillTerminate: ((Notification) -> Void)? = nil 50 | ) { 51 | self.onApplicationDidAppear = onApplicationDidAppear 52 | self.onApplicationWillEnterForeground = onApplicationWillEnterForeground 53 | self.onApplicationDidBecomeActive = onApplicationDidBecomeActive 54 | self.onApplicationWillResignActive = onApplicationWillResignActive 55 | self.onApplicationDidEnterBackground = onApplicationDidEnterBackground 56 | self.onApplicationWillTerminate = onApplicationWillTerminate 57 | } 58 | } 59 | 60 | /// Make this the Root view of your application to recieve UIApplicationDelegate methods in your SwiftUI View. 61 | public struct RootView: View { 62 | 63 | let delegate: RootDelegate? 64 | let content: () -> any View 65 | 66 | public init(delegate: RootDelegate? = nil, content: @escaping () -> any View) { 67 | self.delegate = delegate 68 | self.content = content 69 | } 70 | 71 | public var body: some View { 72 | ZStack { 73 | AnyView(content()) 74 | } 75 | .frame(maxWidth: .infinity, maxHeight: .infinity) 76 | .onFirstAppear { 77 | delegate?.onApplicationDidAppear?() 78 | } 79 | .onNotificationRecieved( 80 | name: UIApplication.willEnterForegroundNotification, 81 | action: { notification in 82 | delegate?.onApplicationWillEnterForeground?(notification) 83 | } 84 | ) 85 | .onNotificationRecieved( 86 | name: UIApplication.didBecomeActiveNotification, 87 | action: { notification in 88 | delegate?.onApplicationDidBecomeActive?(notification) 89 | } 90 | ) 91 | .onNotificationRecieved( 92 | name: UIApplication.willResignActiveNotification, 93 | action: { notification in 94 | delegate?.onApplicationWillResignActive?(notification) 95 | } 96 | ) 97 | .onNotificationRecieved( 98 | name: UIApplication.didEnterBackgroundNotification, 99 | action: { notification in 100 | delegate?.onApplicationDidEnterBackground?(notification) 101 | } 102 | ) 103 | .onNotificationRecieved( 104 | name: UIApplication.willTerminateNotification, 105 | action: { notification in 106 | delegate?.onApplicationWillTerminate?(notification) 107 | } 108 | ) 109 | } 110 | 111 | } 112 | 113 | #Preview("RootView") { 114 | ZStack { 115 | RootView( 116 | delegate: RootDelegate( 117 | onApplicationDidAppear: { 118 | 119 | }, 120 | onApplicationWillEnterForeground: { notification in 121 | 122 | }, 123 | onApplicationDidBecomeActive: { notification in 124 | 125 | }, 126 | onApplicationWillResignActive: { notification in 127 | 128 | }, 129 | onApplicationDidEnterBackground: { notification in 130 | 131 | }, 132 | onApplicationWillTerminate: { notification in 133 | 134 | } 135 | ), 136 | content: { 137 | Text("Home") 138 | } 139 | ) 140 | 141 | let delegate = RootDelegate( 142 | onApplicationDidAppear: nil, 143 | onApplicationWillEnterForeground: nil, 144 | onApplicationDidBecomeActive: nil, 145 | onApplicationWillResignActive: nil, 146 | onApplicationDidEnterBackground: nil, 147 | onApplicationWillTerminate: nil) 148 | 149 | RootView( 150 | delegate: delegate, 151 | content: { 152 | Text("Home") 153 | } 154 | ) 155 | } 156 | } 157 | 158 | -------------------------------------------------------------------------------- /Tests/SwiftfulUITests/SwiftfulUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftfulUI 3 | 4 | final class SwiftfulUITests: XCTestCase { 5 | func testExample() throws { 6 | } 7 | } 8 | --------------------------------------------------------------------------------