├── .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 |
--------------------------------------------------------------------------------