├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Assets
└── OnboardingKitDemo.gif
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── OnboardingKit
│ ├── Assets
│ └── Media.xcassets
│ │ ├── Contents.json
│ │ ├── DataPrivacy.imageset
│ │ ├── Contents.json
│ │ └── DataPrivacyIcon.png
│ │ └── MockAppIcon.imageset
│ │ ├── Contents.json
│ │ └── MockAppIcon.png
│ ├── Extensions
│ ├── Bundle+Extensions.swift
│ ├── Color+Extensions.swift
│ ├── Image+Extensions.swift
│ └── View+Extensions.swift
│ ├── Models
│ ├── FeatureInfo.swift
│ └── OnboardingConfiguration.swift
│ ├── Modifiers
│ └── OnboardingViewModifier.swift
│ ├── OnboardingKit.swift
│ ├── Provider
│ ├── OnboardingProvider.swift
│ └── OnboardingProviding.swift
│ ├── Resources
│ └── Localizable.xcstrings
│ ├── Utilities
│ ├── BlurBackgroundView.swift
│ ├── PrimaryButtonStyle.swift
│ ├── VScrollView.swift
│ └── WebView.swift
│ └── Views
│ ├── OnboardingView.swift
│ ├── Sections
│ ├── BottomSection.swift
│ ├── FeatureSection.swift
│ └── TitleSection.swift
│ └── Subviews
│ ├── DataPrivacyView.swift
│ └── FeatureView.swift
└── Tests
└── OnboardingKitTests
├── LocalizationTests.swift
└── OnboardingKitTests.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Assets/OnboardingKitDemo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JamesSedlacek/OnboardingKit/75afe9d3fca6e3089d6ea065e214d9fa98d34c25/Assets/OnboardingKitDemo.gif
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 James Sedlacek
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.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
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: "OnboardingKit",
8 | defaultLocalization: "en",
9 | platforms: [
10 | .macOS(.v14),
11 | .iOS(.v17)
12 | ],
13 | products: [
14 | .library(
15 | name: "OnboardingKit",
16 | targets: ["OnboardingKit"]),
17 | ],
18 | targets: [
19 | .target(
20 | name: "OnboardingKit",
21 | path: "Sources",
22 | resources: [
23 | .process("Resources/Localizable.xcstrings")
24 | ]
25 | ),
26 | .testTarget(
27 | name: "OnboardingKitTests",
28 | dependencies: ["OnboardingKit"]),
29 | ]
30 | )
31 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # OnboardingKit
2 |
3 | [](https://github.com/apple/swift-package-manager)
4 | [](https://github.com/JamesSedlacek/OnboardingKit/stargazers)
5 | [](https://github.com/JamesSedlacek/OnboardingKit/network)
6 | [](https://github.com/JamesSedlacek/OnboardingKit/network)
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | ## Description
16 | `OnboardingKit` is a SwiftUI library for handling onboarding.
17 |
18 |
19 |
20 | ## Requirements
21 |
22 | - **iOS**: 17.0 or later.
23 | - **macOS**: 14.0 or later.
24 |
25 |
26 |
27 | ## Installation
28 |
29 | You can install `OnboardingKit` using the Swift Package Manager.
30 |
31 | 1. In Xcode, select "File" > "Add Package Dependencies".
32 | 2. Copy & paste the following into the "Search or Enter Package URL" search bar.
33 | ```
34 | https://github.com/JamesSedlacek/OnboardingKit.git
35 | ```
36 | 4. Xcode will fetch the repository & the "OnboardingKit" library will be added to your project.
37 |
38 |
39 |
40 | ## Supported Languages
41 |
42 | This project currently supports the following languages:
43 |
44 | - **English (en)**
45 | - **German (de)**
46 |
47 |
48 |
49 | ## Usage
50 |
51 | 1. Create an `Onboarding Configuration`.
52 | ```swift
53 | import OnboardingKit
54 | import SwiftUI
55 |
56 | extension OnboardingConfiguration {
57 | static let prod = Self.init(privacyUrlString: "",
58 | accentColor: .green,
59 | features: [])
60 | }
61 | ```
62 |
63 |
64 |
65 | 2. Add `.showOnboardingIfNeeded()` to the root view in your project.
66 | ```swift
67 | import OnboardingKit
68 | import SwiftUI
69 |
70 | @main
71 | struct ExampleApp: App {
72 | var body: some Scene {
73 | ContentView()
74 | .showOnboardingIfNeeded(using: .prod)
75 | }
76 | }
77 | ```
78 |
79 |
80 |
81 | ## Author
82 |
83 | James Sedlacek, find me on [X/Twitter](https://twitter.com/jsedlacekjr) or [LinkedIn](https://www.linkedin.com/in/jamessedlacekjr/)
84 |
85 |
86 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Assets/Media.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Assets/Media.xcassets/DataPrivacy.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "DataPrivacyIcon.png",
5 | "idiom" : "universal"
6 | }
7 | ],
8 | "info" : {
9 | "author" : "xcode",
10 | "version" : 1
11 | },
12 | "properties" : {
13 | "preserves-vector-representation" : true,
14 | "template-rendering-intent" : "template"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Assets/Media.xcassets/DataPrivacy.imageset/DataPrivacyIcon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JamesSedlacek/OnboardingKit/75afe9d3fca6e3089d6ea065e214d9fa98d34c25/Sources/OnboardingKit/Assets/Media.xcassets/DataPrivacy.imageset/DataPrivacyIcon.png
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Assets/Media.xcassets/MockAppIcon.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "MockAppIcon.png",
5 | "idiom" : "universal"
6 | }
7 | ],
8 | "info" : {
9 | "author" : "xcode",
10 | "version" : 1
11 | },
12 | "properties" : {
13 | "preserves-vector-representation" : true
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Assets/Media.xcassets/MockAppIcon.imageset/MockAppIcon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JamesSedlacek/OnboardingKit/75afe9d3fca6e3089d6ea065e214d9fa98d34c25/Sources/OnboardingKit/Assets/Media.xcassets/MockAppIcon.imageset/MockAppIcon.png
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Extensions/Bundle+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Bundle+Extensions.swift
3 | //
4 | // Created by James Sedlacek on 12/30/23.
5 | //
6 |
7 | import Foundation
8 |
9 | extension Bundle {
10 | var appIcon: ImageRepresentable? {
11 | if let icons = infoDictionary?["CFBundleIcons"] as? [String: Any],
12 | let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
13 | let files = primary["CFBundleIconFiles"] as? [String],
14 | let icon = files.last {
15 | return .init(named: icon)
16 | } else {
17 | return nil
18 | }
19 | }
20 |
21 | var displayName: String {
22 | infoDictionary?["CFBundleDisplayName"] as? String ?? "Default Display Name"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Extensions/Color+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Color+Extensions.swift
3 | //
4 | // Created by James Sedlacek on 12/31/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | extension Color {
10 | #if canImport(AppKit) && !targetEnvironment(macCatalyst)
11 | static let secondaryBackground = Color(nsColor: .windowBackgroundColor)
12 | #endif
13 |
14 | #if canImport(UIKit)
15 | static let secondaryBackground = Color(uiColor: .secondarySystemGroupedBackground)
16 | #endif
17 | }
18 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Extensions/Image+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Image+Extensions.swift
3 | //
4 | // Created by James Sedlacek on 12/30/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | #if canImport(AppKit) && !targetEnvironment(macCatalyst)
10 | import AppKit
11 | typealias ImageRepresentable = NSImage
12 | #endif
13 |
14 | #if canImport(UIKit)
15 | import UIKit
16 | typealias ImageRepresentable = UIImage
17 | #endif
18 |
19 | extension Image {
20 | init?(_ image: ImageRepresentable?) {
21 | guard let image else { return nil }
22 | #if canImport(UIKit)
23 | self.init(uiImage: image)
24 | #endif
25 | #if canImport(AppKit) && !targetEnvironment(macCatalyst)
26 | self.init(nsImage: image)
27 | #endif
28 | }
29 |
30 | static let appIcon: Image = Image(Bundle.main.appIcon) ?? Image(.mockAppIcon)
31 | }
32 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Extensions/View+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // View+Extensions.swift
3 | //
4 | // Created by James Sedlacek on 12/30/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | public extension View {
10 | /// This function checks if onboarding is needed and shows it if necessary.
11 | /// - Parameters:
12 | /// - storage: The UserDefaults storage to use. Defaults to .standard.
13 | /// - config: The configuration for the onboarding.
14 | /// - Returns: The original view, modified with the onboarding view modifier.
15 | func showOnboardingIfNeeded(
16 | storage: UserDefaults = .standard,
17 | using config: OnboardingConfiguration
18 | ) -> some View {
19 | modifier(OnboardingViewModifier(storage: storage, config: config))
20 | }
21 | }
22 |
23 | #Preview {
24 | Text("Hello, World!")
25 | .showOnboardingIfNeeded(using: .mock)
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Models/FeatureInfo.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeatureInfo.swift
3 | //
4 | // Created by James Sedlacek on 12/30/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | public struct FeatureInfo: Identifiable {
10 | public let id: UUID = .init()
11 | let image: Image
12 | let title: String
13 | let content: String
14 |
15 | public init(image: Image, title: String, content: String) {
16 | self.image = image
17 | self.title = title
18 | self.content = content
19 | }
20 | }
21 |
22 | extension FeatureInfo {
23 | static let mock: FeatureInfo = .init(image: Image(systemName: "barcode.viewfinder"),
24 | title: "Self-checkout at the Apple Store.",
25 | content: "Scan to pay for certain products right from your iPhone — no employee assistance required.")
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Models/OnboardingConfiguration.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OnboardingConfiguration.swift
3 | //
4 | // Created by James Sedlacek on 12/30/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | public struct OnboardingConfiguration {
10 | let privacyUrlString: String
11 | let accentColor: Color
12 | let features: [FeatureInfo]
13 | let titleSectionAlignment: HorizontalAlignment
14 |
15 | public init(privacyUrlString: String,
16 | accentColor: Color = .blue,
17 | features: [FeatureInfo],
18 | titleSectionAlignment: HorizontalAlignment = .leading) {
19 | self.privacyUrlString = privacyUrlString
20 | self.accentColor = accentColor
21 | self.features = features
22 | self.titleSectionAlignment = titleSectionAlignment
23 | }
24 | }
25 |
26 | public extension OnboardingConfiguration {
27 | static let mock = Self.init(privacyUrlString: "https://www.apple.com/legal/privacy/data/en/app-store/",
28 | features: [.init(image: Image(systemName: "iphone"),
29 | title: "Find your perfect match.",
30 | content: "Easily check what's compatible with your devices. And browse product recommendations in the app."),
31 | .init(image: Image(systemName: "barcode.viewfinder"),
32 | title: "Self-checkout at the Apple Store.",
33 | content: "Scan to pay for certain products right from your iPhone — no employee assistance required."),
34 | .init(image: Image(systemName: "shippingbox"),
35 | title: "Track your deliveries.",
36 | content: "Get real-time updates on orders, all the way from your shopping bag to your home.")])
37 | }
38 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Modifiers/OnboardingViewModifier.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OnboardingViewModifier.swift
3 | //
4 | // Created by James Sedlacek on 12/26/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | struct OnboardingViewModifier: ViewModifier {
10 | @State private var onboardingProvider: OnboardingProvider
11 |
12 | init(storage: UserDefaults = .standard, config: OnboardingConfiguration) {
13 | _onboardingProvider = .init(initialValue: OnboardingProvider(storage: storage, configuration: config))
14 | }
15 |
16 | func body(content: Content) -> some View {
17 | Group {
18 | if onboardingProvider.isShowingOnboarding {
19 | OnboardingView()
20 | } else {
21 | content
22 | }
23 | }
24 | .environment(onboardingProvider)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/OnboardingKit.swift:
--------------------------------------------------------------------------------
1 | // The Swift Programming Language
2 | // https://docs.swift.org/swift-book
3 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Provider/OnboardingProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OnboardingProvider.swift
3 | //
4 | // Created by James Sedlacek on 12/26/23.
5 | //
6 |
7 | import Foundation
8 |
9 | @Observable
10 | public final class OnboardingProvider: OnboardingProviding {
11 | private static let onboardingKey = "isOnboardingCompleted"
12 | private let userDefaults: UserDefaults
13 | let configuration: OnboardingConfiguration
14 |
15 | public var isCompleted: Bool {
16 | get {
17 | access(keyPath: \.isCompleted)
18 | return userDefaults.bool(forKey: Self.onboardingKey)
19 | }
20 | set {
21 | withMutation(keyPath: \.isCompleted) {
22 | userDefaults.set(newValue, forKey: Self.onboardingKey)
23 | }
24 | }
25 | }
26 |
27 | public init(
28 | storage: UserDefaults = .standard,
29 | configuration: OnboardingConfiguration
30 | ) {
31 | self.userDefaults = storage
32 | self.configuration = configuration
33 | }
34 | }
35 |
36 | extension OnboardingProvider {
37 | static let mock: OnboardingProvider = .init(configuration: .mock)
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Provider/OnboardingProviding.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OnboardingProviding.swift
3 | //
4 | // Created by James Sedlacek on 12/26/23.
5 | //
6 |
7 | import Foundation
8 |
9 | protocol OnboardingProviding: AnyObject, Observable {
10 | var isCompleted: Bool { get set }
11 | var isShowingOnboarding: Bool { get }
12 | var configuration: OnboardingConfiguration { get }
13 |
14 | func completeOnboarding()
15 | }
16 |
17 | extension OnboardingProviding {
18 | var isShowingOnboarding: Bool {
19 | !isCompleted
20 | }
21 |
22 | func completeOnboarding() {
23 | isCompleted = true
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Resources/Localizable.xcstrings:
--------------------------------------------------------------------------------
1 | {
2 | "sourceLanguage" : "en",
3 | "strings" : {
4 | "404 Error" : {
5 |
6 | },
7 | "Close" : {
8 |
9 | },
10 | "Continue" : {
11 |
12 | },
13 | "continue_button" : {
14 | "extractionState" : "manual",
15 | "localizations" : {
16 | "de" : {
17 | "stringUnit" : {
18 | "state" : "translated",
19 | "value" : "Fortsetzen"
20 | }
21 | },
22 | "en" : {
23 | "stringUnit" : {
24 | "state" : "translated",
25 | "value" : "Continue"
26 | }
27 | }
28 | }
29 | },
30 | "data_collection_info" : {
31 | "extractionState" : "manual",
32 | "localizations" : {
33 | "de" : {
34 | "stringUnit" : {
35 | "state" : "needs_review",
36 | "value" : "%@ erfasst Ihre Aktivitäten, die nicht mit Ihrer Apple-ID verknüpft sind. "
37 | }
38 | },
39 | "en" : {
40 | "stringUnit" : {
41 | "state" : "translated",
42 | "value" : "%@ collects your activity, which is not associated with your Apple ID."
43 | }
44 | }
45 | }
46 | },
47 | "data_management" : {
48 | "extractionState" : "manual",
49 | "localizations" : {
50 | "de" : {
51 | "stringUnit" : {
52 | "state" : "translated",
53 | "value" : "Erfahren Sie, wie Ihre Daten verwaltet werden..."
54 | }
55 | },
56 | "en" : {
57 | "stringUnit" : {
58 | "state" : "translated",
59 | "value" : "See how your data is managed..."
60 | }
61 | }
62 | }
63 | },
64 | "Page Not Found" : {
65 |
66 | },
67 | "welcome_to" : {
68 | "extractionState" : "manual",
69 | "localizations" : {
70 | "de" : {
71 | "stringUnit" : {
72 | "state" : "needs_review",
73 | "value" : "Willkommen bei"
74 | }
75 | },
76 | "en" : {
77 | "stringUnit" : {
78 | "state" : "translated",
79 | "value" : "Welcome to"
80 | }
81 | }
82 | }
83 | }
84 | },
85 | "version" : "1.0"
86 | }
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Utilities/BlurBackgroundView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | // Created by James Sedlacek on 12/30/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | #if canImport(UIKit)
10 | struct BlurView: UIViewRepresentable {
11 | static let background = BlurView(style: .regular)
12 | let style: UIBlurEffect.Style
13 |
14 | func makeUIView(context: Context) -> UIVisualEffectView {
15 | let blurEffect = UIBlurEffect(style: style)
16 | let blurEffectView = UIVisualEffectView(effect: blurEffect)
17 | blurEffectView.backgroundColor = UIAccessibility.isReduceTransparencyEnabled ? .systemBackground : nil
18 | blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
19 | return blurEffectView
20 | }
21 |
22 | func updateUIView(_ uiView: UIVisualEffectView, context: Context) {}
23 | }
24 | #endif
25 |
26 | #if canImport(AppKit) && !targetEnvironment(macCatalyst)
27 | struct BlurView: NSViewRepresentable {
28 | static let background = BlurView(style: .withinWindow)
29 | let style: NSVisualEffectView.BlendingMode
30 |
31 | func makeNSView(context: Context) -> NSVisualEffectView {
32 | let blurEffectView = NSVisualEffectView()
33 | blurEffectView.blendingMode = style
34 | blurEffectView.state = .active
35 | blurEffectView.autoresizingMask = [.width, .height]
36 | return blurEffectView
37 | }
38 |
39 | func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
40 | }
41 | #endif
42 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Utilities/PrimaryButtonStyle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PrimaryButtonStyle.swift
3 | //
4 | // Created by James Sedlacek on 12/30/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | struct PrimaryButtonStyle: ButtonStyle {
10 | @Environment(OnboardingProvider.self)
11 | private var onboardingProvider
12 |
13 | func makeBody(configuration: Configuration) -> some View {
14 | configuration.label
15 | .font(.headline)
16 | .padding(12)
17 | .frame(maxWidth: .infinity)
18 | .foregroundStyle(.white)
19 | .background(onboardingProvider.configuration.accentColor)
20 | .clipShape(.rect(cornerRadius: 10))
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Utilities/VScrollView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VScrollView.swift
3 | //
4 | // Created by James Sedlacek on 12/30/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | /// Custom vertical scroll view with centered content vertically
10 | struct VScrollView: View {
11 | @ViewBuilder let content: Content
12 |
13 | var body: some View {
14 | GeometryReader { geometry in
15 | ScrollView(.vertical, showsIndicators: false) {
16 | content
17 | .frame(width: geometry.size.width)
18 | .frame(minHeight: geometry.size.height)
19 | }
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Utilities/WebView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WebView.swift
3 | //
4 | // Created by James Sedlacek on 12/30/23.
5 | //
6 |
7 | import SwiftUI
8 | import WebKit
9 |
10 | #if canImport(UIKit)
11 | struct WebView: UIViewRepresentable {
12 | let url: URL
13 |
14 | func makeUIView(context: Context) -> WKWebView { .init() }
15 |
16 | func updateUIView(_ webView: WKWebView, context: Context) {
17 | let request = URLRequest(url: url)
18 | webView.load(request)
19 | }
20 | }
21 | #endif
22 |
23 | #if canImport(AppKit) && !targetEnvironment(macCatalyst)
24 | struct WebView: NSViewRepresentable {
25 | let url: URL
26 |
27 | func makeNSView(context: Context) -> WKWebView { .init() }
28 |
29 | func updateNSView(_ webView: WKWebView, context: Context) {
30 | let request = URLRequest(url: url)
31 | webView.load(request)
32 | }
33 | }
34 | #endif
35 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Views/OnboardingView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OnboardingView.swift
3 | //
4 | // Created by James Sedlacek on 12/30/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | struct OnboardingView: View {
10 | @Environment(OnboardingProvider.self)
11 | private var onboardingProvider
12 | @State private var isAnimating = false
13 |
14 | var body: some View {
15 | VScrollView {
16 | VStack(alignment: .center, spacing: 40) {
17 | TitleSection(shouldHideAppIcon: !isAnimating)
18 | .offset(y: isAnimating ? 0 : 200)
19 | FeatureSection(features: onboardingProvider.configuration.features,
20 | accentColor: onboardingProvider.configuration.accentColor)
21 | .opacity(isAnimating ? 1 : 0)
22 | .padding(.horizontal, 48)
23 | }
24 | .padding(.vertical, 24)
25 | }
26 | .background(Color.secondaryBackground)
27 | .safeAreaInset(edge: .bottom) {
28 | BottomSection()
29 | .opacity(isAnimating ? 1 : 0)
30 | }
31 | .onAppear {
32 | withAnimation(.easeInOut(duration: 0.8).delay(1.6)) {
33 | isAnimating = true
34 | }
35 | }
36 | }
37 | }
38 |
39 | #Preview {
40 | OnboardingView()
41 | .environment(OnboardingProvider.mock)
42 | }
43 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Views/Sections/BottomSection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BottomSection.swift
3 | //
4 | //
5 | // Created by James Sedlacek on 12/30/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct BottomSection: View {
11 | @Environment(OnboardingProvider.self)
12 | private var onboardingProvider
13 | @State private var isDataPrivacyPresented = false
14 | @State private var isAnimating: Bool = false
15 |
16 | var body: some View {
17 | VStack(alignment: .center, spacing: .zero) {
18 | Image(.dataPrivacy)
19 | .resizable()
20 | .foregroundStyle(onboardingProvider.configuration.accentColor)
21 | .frame(width: 32, height: 32)
22 | Group {
23 | let dataCollectionInfoMsg = String.localizedStringWithFormat(
24 | NSLocalizedString("data_collection_info", bundle: .module, comment: "Data collection info"),
25 | Bundle.main.displayName
26 | )
27 | Text(dataCollectionInfoMsg)
28 | .foregroundStyle(.secondary) +
29 | Text("data_management", bundle: .module, comment: "Data management info")
30 | .foregroundStyle(onboardingProvider.configuration.accentColor)
31 | .bold()
32 | }
33 | .multilineTextAlignment(.center)
34 | .font(.system(size: 10))
35 | .padding(.bottom, 24)
36 | .padding(.top, 4)
37 | .onTapGesture {
38 | isDataPrivacyPresented.toggle()
39 | }
40 |
41 | Button("Continue") {
42 | onboardingProvider.completeOnboarding()
43 | }
44 | .buttonStyle(PrimaryButtonStyle())
45 | }
46 | .padding(.horizontal, 28)
47 | .padding(.vertical, 24)
48 | .background(BlurView.background)
49 | .opacity(isAnimating ? 1 : 0)
50 | .sheet(isPresented: $isDataPrivacyPresented) {
51 | DataPrivacyView(accentColor: onboardingProvider.configuration.accentColor,
52 | urlString: onboardingProvider.configuration.privacyUrlString)
53 | }
54 | .onAppear {
55 | withAnimation(.easeInOut(duration: 0.8).delay(2.2)) {
56 | isAnimating = true
57 | }
58 | }
59 | }
60 | }
61 |
62 | #Preview {
63 | VStack {
64 | Spacer()
65 | }
66 | .safeAreaInset(edge: .bottom) {
67 | BottomSection()
68 | }
69 | .environment(OnboardingProvider.mock)
70 | }
71 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Views/Sections/FeatureSection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUIView.swift
3 | //
4 | //
5 | // Created by James Sedlacek on 12/30/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct FeatureSection: View {
11 | @State private var isAnimating: [Bool]
12 | private let features: [FeatureInfo]
13 | private let accentColor: Color
14 |
15 | init(features: [FeatureInfo], accentColor: Color) {
16 | self._isAnimating = State(initialValue: Array(repeating: false, count: features.count))
17 | self.features = features
18 | self.accentColor = accentColor
19 | }
20 |
21 | var body: some View {
22 | ForEach(features.indices, id: \.self) { index in
23 | FeatureView(info: features[index], accentColor: accentColor)
24 | .opacity(isAnimating[index] ? 1 : 0)
25 | .offset(y: isAnimating[index] ? 0 : 100)
26 | .onAppear {
27 | withAnimation(.easeInOut(duration: 0.8).delay(1.6 + Double(index) * 0.16)) {
28 | isAnimating[index] = true
29 | }
30 | }
31 | }
32 | }
33 | }
34 |
35 | #Preview {
36 | VScrollView {
37 | VStack(spacing: 20) {
38 | FeatureSection(features: OnboardingConfiguration.mock.features, accentColor: .blue)
39 | }
40 | .padding(40)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Views/Sections/TitleSection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TitleSection.swift
3 | //
4 | //
5 | // Created by James Sedlacek on 12/30/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct TitleSection: View {
11 | @Environment(OnboardingProvider.self)
12 | private var onboardingProvider
13 | @State private var isAnimating = false
14 | let shouldHideAppIcon: Bool
15 |
16 | var body: some View {
17 | VStack(alignment: onboardingProvider.configuration.titleSectionAlignment, spacing: 2) {
18 | if shouldHideAppIcon {
19 | Image.appIcon
20 | .resizable()
21 | .frame(width: 60, height: 60)
22 | .clipShape(.rect(cornerRadius: 10))
23 | .padding(.bottom)
24 | }
25 | Text("welcome_to", bundle: .module, comment: "Welcome text")
26 | .foregroundColor(.primary)
27 | .fontWeight(.semibold)
28 | Text(Bundle.main.displayName)
29 | .foregroundStyle(onboardingProvider.configuration.accentColor)
30 | .fontWeight(.bold)
31 | }
32 | .font(.system(size: 38))
33 | .opacity(isAnimating ? 1 : 0)
34 | .scaleEffect(isAnimating ? 1.0 : 0.5)
35 | .onAppear {
36 | withAnimation(.easeInOut(duration: 0.8)) {
37 | isAnimating = true
38 | }
39 | }
40 | }
41 | }
42 |
43 | #Preview {
44 | TitleSection(shouldHideAppIcon: true)
45 | .padding(40)
46 | .environment(OnboardingProvider.mock)
47 | }
48 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Views/Subviews/DataPrivacyView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DataPrivacyView.swift
3 | //
4 | // Created by James Sedlacek on 12/30/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | struct DataPrivacyView: View {
10 | @Environment(\.dismiss)
11 | private var dismiss
12 | private let accentColor: Color
13 | private let url: URL?
14 |
15 | init(accentColor: Color, urlString: String) {
16 | self.accentColor = accentColor
17 | self.url = URL(string: urlString)
18 | }
19 |
20 | var body: some View {
21 | Group {
22 | if let url {
23 | WebView(url: url)
24 | } else {
25 | VStack(alignment: .leading) {
26 | Text("404 Error")
27 | Text("Page Not Found")
28 | .foregroundStyle(accentColor)
29 | }
30 | .frame(maxWidth: .infinity, maxHeight: .infinity)
31 | .font(.system(size: 40, weight: .semibold))
32 | }
33 | }
34 | .overlay {
35 | VStack {
36 | Spacer()
37 | Button("Close") {
38 | dismiss()
39 | }
40 | .buttonStyle(PrimaryButtonStyle())
41 | .padding(.horizontal, 28)
42 | .padding(.bottom, 24)
43 | }
44 | }
45 | }
46 | }
47 |
48 | #Preview {
49 | DataPrivacyView(accentColor: .blue, urlString: "")
50 | }
51 |
--------------------------------------------------------------------------------
/Sources/OnboardingKit/Views/Subviews/FeatureView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeatureView.swift
3 | //
4 | // Created by James Sedlacek on 12/30/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | struct FeatureView: View {
10 | let info: FeatureInfo
11 | let accentColor: Color
12 |
13 | var body: some View {
14 | HStack(spacing: 12) {
15 | info.image
16 | .font(.title)
17 | .foregroundStyle(accentColor)
18 | .frame(minWidth: 50)
19 | VStack(alignment: .leading, spacing: 4) {
20 | Text(info.title)
21 | .foregroundStyle(.primary)
22 | .font(.system(size: 13, weight: .semibold))
23 | Text(info.content)
24 | .foregroundStyle(.secondary)
25 | .font(.system(size: 12, weight: .regular))
26 | }
27 | Spacer()
28 | }
29 | }
30 | }
31 |
32 | #Preview {
33 | FeatureView(info: .mock, accentColor: .blue)
34 | .padding(40)
35 | }
36 |
--------------------------------------------------------------------------------
/Tests/OnboardingKitTests/LocalizationTests.swift:
--------------------------------------------------------------------------------
1 | @testable import OnboardingKit
2 | import XCTest
3 |
4 | final class LocalizationTest: XCTestCase {
5 | final class LocalizationTest: XCTestCase {
6 | func testLocalizationWorksInGeneral() {
7 | // This test verifies that the localization system is functional by checking
8 | // that a specific key ('welcome_to') returns a properly localized string
9 | // instead of the key itself or an empty string.
10 | let keyToCheck = "welcome_to"
11 |
12 | guard let localizedString = NSLocalizedString(keyToCheck, tableName: nil, bundle: .module, value: "", comment: "") as String?, !localizedString.isEmpty else {
13 | XCTFail("Missing or empty localized string for key '\(keyToCheck)'")
14 | return
15 | }
16 |
17 | XCTAssertNotEqual(localizedString, keyToCheck, "Localized string for key '\(keyToCheck)' is not properly localized.")
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Tests/OnboardingKitTests/OnboardingKitTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import OnboardingKit
3 |
4 | final class OnboardingKitTests: XCTestCase {
5 | func testExample() throws {
6 | // XCTest Documentation
7 | // https://developer.apple.com/documentation/xctest
8 |
9 | // Defining Test Cases and Test Methods
10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
11 | }
12 | }
13 |
--------------------------------------------------------------------------------