├── .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 | [![Swift Package Manager](https://img.shields.io/badge/Swift%20Package%20Manager-compatible-brightgreen.svg)](https://github.com/apple/swift-package-manager) 4 | [![GitHub stars](https://img.shields.io/github/stars/JamesSedlacek/OnboardingKit.svg)](https://github.com/JamesSedlacek/OnboardingKit/stargazers) 5 | [![GitHub forks](https://img.shields.io/github/forks/JamesSedlacek/OnboardingKit.svg?color=blue)](https://github.com/JamesSedlacek/OnboardingKit/network) 6 | [![GitHub contributors](https://img.shields.io/github/contributors/JamesSedlacek/OnboardingKit.svg?color=blue)](https://github.com/JamesSedlacek/OnboardingKit/network) 7 | Pull Requests Badge 8 | Issues Badge 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 | --------------------------------------------------------------------------------