From 6d58489697fc8687d2e67176029d7aa1143ca68c Mon Sep 17 00:00:00 2001 From: Tomasz Kurylik Date: Sat, 3 Jun 2023 02:40:06 +0200 Subject: [PATCH] Initial commit --- .gitignore | 102 ++++++++++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + LICENSE | 21 +++ Package.swift | 17 ++ README.md | 3 + .../Configurables/TransitionAnimation.swift | 17 ++ .../Extensions/Foundation/Array++.swift | 29 ++++ .../Extensions/Foundation/Equatable++.swift | 15 ++ .../Extensions/Foundation/Error++.swift | 13 ++ .../Extensions/Views/NavigatableView++.swift | 15 ++ .../Extensions/Views/UIApplication++.swift | 17 ++ .../Extensions/Views/UIScreen++.swift | 26 +++ .../Navigattie/Extensions/Views/View++.swift | 22 +++ .../Managers/NavigationManager.swift | 93 +++++++++++ .../Protocols/NavigatableView.swift | 34 ++++ .../Type Erasers/AnyNavigatableView.swift | 30 ++++ .../AnimationCompletionModifier.swift | 37 +++++ .../Views/NavigationStackView.swift | 148 ++++++++++++++++++ Sources/Navigattie/Views/NavigationView.swift | 18 +++ 19 files changed, 665 insertions(+) create mode 100644 .gitignore create mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/Navigattie/Configurables/TransitionAnimation.swift create mode 100644 Sources/Navigattie/Extensions/Foundation/Array++.swift create mode 100644 Sources/Navigattie/Extensions/Foundation/Equatable++.swift create mode 100644 Sources/Navigattie/Extensions/Foundation/Error++.swift create mode 100644 Sources/Navigattie/Extensions/Views/NavigatableView++.swift create mode 100644 Sources/Navigattie/Extensions/Views/UIApplication++.swift create mode 100644 Sources/Navigattie/Extensions/Views/UIScreen++.swift create mode 100644 Sources/Navigattie/Extensions/Views/View++.swift create mode 100644 Sources/Navigattie/Managers/NavigationManager.swift create mode 100644 Sources/Navigattie/Protocols/NavigatableView.swift create mode 100644 Sources/Navigattie/Type Erasers/AnyNavigatableView.swift create mode 100644 Sources/Navigattie/View Modifiers/AnimationCompletionModifier.swift create mode 100644 Sources/Navigattie/Views/NavigationStackView.swift create mode 100644 Sources/Navigattie/Views/NavigationView.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48f34b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,102 @@ +<<<<<<< HEAD +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ +======= +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +>>>>>>> bfd2701 (Initial Commit) diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b0da13c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Mijick + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..4775843 --- /dev/null +++ b/Package.swift @@ -0,0 +1,17 @@ +// swift-tools-version: 5.8 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Navigattie", + platforms: [ + .iOS(.v15) + ], + products: [ + .library(name: "Navigattie", targets: ["Navigattie"]), + ], + targets: [ + .target(name: "Navigattie", dependencies: []) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b4d0e6 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Navigattie + +A description of this package. diff --git a/Sources/Navigattie/Configurables/TransitionAnimation.swift b/Sources/Navigattie/Configurables/TransitionAnimation.swift new file mode 100644 index 0000000..113574e --- /dev/null +++ b/Sources/Navigattie/Configurables/TransitionAnimation.swift @@ -0,0 +1,17 @@ +// +// TransitionAnimation.swift of Navigattie +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import Foundation + +public enum TransitionAnimation { + case no + case dissolve + case horizontalSlide, verticalSlide +} diff --git a/Sources/Navigattie/Extensions/Foundation/Array++.swift b/Sources/Navigattie/Extensions/Foundation/Array++.swift new file mode 100644 index 0000000..0fb4c4c --- /dev/null +++ b/Sources/Navigattie/Extensions/Foundation/Array++.swift @@ -0,0 +1,29 @@ +// +// Array++.swift of Navigattie +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import Foundation + +extension Array { + @inlinable mutating func append(_ newElement: Element, if prerequisite: Bool) { if prerequisite { + append(newElement) + }} + @inlinable mutating func removeLastExceptFirst() { if count > 1 { + removeLast() + }} + @inlinable mutating func removeAllExceptFirst() { if count > 1 { + removeLast(count - 1) + }} + @inlinable mutating func removeLastTo(elementWhere predicate: (Element) throws -> Bool) rethrows { if let index = try lastIndex(where: predicate) { + removeLast(count - index - 1) + }} +} +extension Array { + var isNextToLast: Element? { count >= 2 ? self[count - 2] : nil } +} diff --git a/Sources/Navigattie/Extensions/Foundation/Equatable++.swift b/Sources/Navigattie/Extensions/Foundation/Equatable++.swift new file mode 100644 index 0000000..06e272c --- /dev/null +++ b/Sources/Navigattie/Extensions/Foundation/Equatable++.swift @@ -0,0 +1,15 @@ +// +// Equatable++.swift of Navigattie +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import Foundation + +extension Equatable { + func isOne(of other: Self?...) -> Bool { other.contains(self) } +} diff --git a/Sources/Navigattie/Extensions/Foundation/Error++.swift b/Sources/Navigattie/Extensions/Foundation/Error++.swift new file mode 100644 index 0000000..911cf61 --- /dev/null +++ b/Sources/Navigattie/Extensions/Foundation/Error++.swift @@ -0,0 +1,13 @@ +// +// Error++.swift of Navigattie +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import Foundation + +extension String: Error {} diff --git a/Sources/Navigattie/Extensions/Views/NavigatableView++.swift b/Sources/Navigattie/Extensions/Views/NavigatableView++.swift new file mode 100644 index 0000000..250dca5 --- /dev/null +++ b/Sources/Navigattie/Extensions/Views/NavigatableView++.swift @@ -0,0 +1,15 @@ +// +// NavigatableView++.swift of Navigattie +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +public extension NavigatableView { + func implementNavigationView() -> some View { NavigationView(rootView: self) } +} diff --git a/Sources/Navigattie/Extensions/Views/UIApplication++.swift b/Sources/Navigattie/Extensions/Views/UIApplication++.swift new file mode 100644 index 0000000..8bb9707 --- /dev/null +++ b/Sources/Navigattie/Extensions/Views/UIApplication++.swift @@ -0,0 +1,17 @@ +// +// UIApplication++.swift of Navigattie +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +extension UIApplication { + func hideKeyboard() { + sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} diff --git a/Sources/Navigattie/Extensions/Views/UIScreen++.swift b/Sources/Navigattie/Extensions/Views/UIScreen++.swift new file mode 100644 index 0000000..6ae5a9b --- /dev/null +++ b/Sources/Navigattie/Extensions/Views/UIScreen++.swift @@ -0,0 +1,26 @@ +// +// UIScreen++.swift of Navigattie +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +extension UIScreen { + static let width: CGFloat = main.bounds.size.width + static let height: CGFloat = main.bounds.size.height + static let safeArea: UIEdgeInsets = { + UIApplication.shared.connectedScenes + .filter({$0.activationState == .foregroundActive}) + .map({$0 as? UIWindowScene}) + .compactMap({$0}) + .first?.windows + .filter({$0.isKeyWindow}) + .first? + .safeAreaInsets ?? .zero + }() +} diff --git a/Sources/Navigattie/Extensions/Views/View++.swift b/Sources/Navigattie/Extensions/Views/View++.swift new file mode 100644 index 0000000..17e9c30 --- /dev/null +++ b/Sources/Navigattie/Extensions/Views/View++.swift @@ -0,0 +1,22 @@ +// +// View++.swift of Navigattie +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +public extension View { + @ViewBuilder func matchedGeometryEffect(id: String, properties: MatchedGeometryProperties = .frame, anchor: UnitPoint = .center, isSource: Bool = true) -> some View { + if let namespace = NavigationManager.shared.namespace { matchedGeometryEffect(id: id, in: namespace, properties: properties, anchor: anchor, isSource: isSource) } + else { self } + } +} + +extension View { + func modify(_ builder: (Self) -> V) -> some View { builder(self) } +} diff --git a/Sources/Navigattie/Managers/NavigationManager.swift b/Sources/Navigattie/Managers/NavigationManager.swift new file mode 100644 index 0000000..8fafa62 --- /dev/null +++ b/Sources/Navigattie/Managers/NavigationManager.swift @@ -0,0 +1,93 @@ +// +// NavigationManager.swift of Navigattie +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +public extension NavigationManager { + /// Returns to a previous view on the stack + static func pop() { performOperation(.removeLast) } + + /// Returns to view with provided type + static func pop(to view: N) { performOperation(.removeAll(toID: view.id)) } + + /// Returns to a root view + static func popToRoot() { performOperation(.removeAllExceptFirst) } +} + + +// MARK: - Internal +public class NavigationManager: ObservableObject { + @Published private(set) var views: [AnyNavigatableView] = [] { willSet { onViewsWillUpdate(newValue) } } + private(set) var transitionType: TransitionType = .push + private(set) var transitionAnimation: TransitionAnimation = .no + private(set) var transitionsBlocked: Bool = false + private(set) var namespace: Namespace.ID? + + static let shared: NavigationManager = .init() + private init() {} +} + +// MARK: - Operations Handler +extension NavigationManager { + static func push(_ view: some NavigatableView, _ animation: TransitionAnimation) { performOperation(.insert(view, animation)) } +} +private extension NavigationManager { + static func performOperation(_ operation: Operation) { if !NavigationManager.shared.transitionsBlocked { + DispatchQueue.main.async { withAnimation(nil) { + shared.views.perform(operation) + }} + }} +} + +// MARK: - Setters +extension NavigationManager { + static func setRoot(_ rootView: some NavigatableView) { shared.views = [.init(rootView, .no)] } + static func setNamespace(_ value: Namespace.ID) { if shared.namespace == nil { shared.namespace = value } } + static func blockTransitions(_ value: Bool) { shared.transitionsBlocked = value } +} + +// MARK: - On Views Will Update +private extension NavigationManager { + func onViewsWillUpdate(_ newValue: [AnyNavigatableView]) { + transitionType = newValue.count > views.count ? .push : .pop + transitionAnimation = (transitionType == .push ? newValue.last?.animation : views.last?.animation) ?? .no + } +} + +// MARK: - Transition Type +enum TransitionType { case pop, push } + +// MARK: - Array Operations +fileprivate enum Operation { + case insert(any NavigatableView, TransitionAnimation) + case removeLast, removeAll(toID: String), removeAllExceptFirst +} +fileprivate extension [AnyNavigatableView] { + mutating func perform(_ operation: Operation) { if !NavigationManager.shared.transitionsBlocked { + hideKeyboard() + performOperation(operation) + }} +} +private extension [AnyNavigatableView] { + func hideKeyboard() { + UIApplication.shared.hideKeyboard() + } + mutating func performOperation(_ operation: Operation) { + switch operation { + case .insert(let view, let animation): append(.init(view, animation), if: canBeInserted(view)) + case .removeLast: removeLastExceptFirst() + case .removeAll(let id): removeLastTo(elementWhere: { $0.id == id }) + case .removeAllExceptFirst: removeAllExceptFirst() + } + } +} +private extension [AnyNavigatableView] { + func canBeInserted(_ view: any NavigatableView) -> Bool { !contains(where: { $0.id == view.id }) } +} diff --git a/Sources/Navigattie/Protocols/NavigatableView.swift b/Sources/Navigattie/Protocols/NavigatableView.swift new file mode 100644 index 0000000..1e2c2e4 --- /dev/null +++ b/Sources/Navigattie/Protocols/NavigatableView.swift @@ -0,0 +1,34 @@ +// +// NavigatableView.swift of Navigattie +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +public protocol NavigatableView: View {} + +// MARK: - Pushing and Removing From Stack +public extension NavigatableView { + /// Pushes a new view. Stacks previous one + func push(with animation: TransitionAnimation) { NavigationManager.push(self, animation) } +} +public extension NavigatableView { + /// Removes the current view from the stack + func pop() { NavigationManager.pop() } + + /// Removes all views up to the selected view in the stack. The view from the argument will be the new active view + func pop(to view: N) { NavigationManager.pop(to: view) } + + /// Removes all views from the stack. Root view will be the new active view + func popToRoot() { NavigationManager.popToRoot() } +} + +// MARK: - Others +extension NavigatableView { + var id: String { .init(describing: Self.self) } +} diff --git a/Sources/Navigattie/Type Erasers/AnyNavigatableView.swift b/Sources/Navigattie/Type Erasers/AnyNavigatableView.swift new file mode 100644 index 0000000..b22a42c --- /dev/null +++ b/Sources/Navigattie/Type Erasers/AnyNavigatableView.swift @@ -0,0 +1,30 @@ +// +// AnyNavigatableView.swift of Navigattie +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +struct AnyNavigatableView: NavigatableView, Equatable { + let id: String + let animation: TransitionAnimation + private let _body: AnyView + + + init(_ view: some NavigatableView, _ animation: TransitionAnimation) { + self.id = view.id + self.animation = animation + self._body = AnyView(view) + } +} +extension AnyNavigatableView { + static func == (lhs: AnyNavigatableView, rhs: AnyNavigatableView) -> Bool { lhs.id == rhs.id } +} +extension AnyNavigatableView { + var body: some View { _body } +} diff --git a/Sources/Navigattie/View Modifiers/AnimationCompletionModifier.swift b/Sources/Navigattie/View Modifiers/AnimationCompletionModifier.swift new file mode 100644 index 0000000..8da8557 --- /dev/null +++ b/Sources/Navigattie/View Modifiers/AnimationCompletionModifier.swift @@ -0,0 +1,37 @@ +// +// AnimationCompletionModifier.swift of Navigattie +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +extension View { + func onAnimationCompleted(for value: V, perform action: @escaping () -> ()) -> some View { modifier(Modifier(observedValue: value, completion: action)) } +} + + +// MARK: - Implementation +fileprivate struct Modifier: AnimatableModifier { + var animatableData: V { didSet { notifyCompletionIfFinished() }} + private var targetValue: V + private var completion: () -> () + + + init(observedValue: V, completion: @escaping () -> ()) { + self.animatableData = observedValue + self.targetValue = observedValue + self.completion = completion + } + func body(content: Content) -> some View { content } +} + +private extension Modifier { + func notifyCompletionIfFinished() { if animatableData == targetValue { + DispatchQueue.main.async { self.completion() } + }} +} diff --git a/Sources/Navigattie/Views/NavigationStackView.swift b/Sources/Navigattie/Views/NavigationStackView.swift new file mode 100644 index 0000000..cc0c7ea --- /dev/null +++ b/Sources/Navigattie/Views/NavigationStackView.swift @@ -0,0 +1,148 @@ +// +// NavigationStackView.swift of Navigattie +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +struct NavigationStackView: View { + @ObservedObject private var stack: NavigationManager = .shared + @State private var temporaryViews: [AnyNavigatableView] = [] + @State private var animatableOffset: CGFloat = 0 + @State private var animatableOpacity: CGFloat = 1 + + + init(namespace: Namespace.ID) { self._temporaryViews = .init(initialValue: NavigationManager.shared.views); NavigationManager.setNamespace(namespace) } + var body: some View { + ZStack(content: createStack) + .onChange(of: stack.views, perform: onViewsChanged) + .onAnimationCompleted(for: animatableOpacity, perform: onAnimationCompleted) + } +} + +private extension NavigationStackView { + func createStack() -> some View { + ForEach(temporaryViews, id: \.id, content: createItem) + } +} + +private extension NavigationStackView { + func createItem(_ item: AnyNavigatableView) -> some View { + item + .transition(.identity) + .opacity(getOpacity(item)) + .offset(getOffset(item)) + .compositingGroup() + } +} + +// MARK: - Calculating Opacity +private extension NavigationStackView { + func getOpacity(_ view: AnyNavigatableView) -> CGFloat { + do { + try checkOpacityPrerequisites(view) + + let isLastView = isLastView(view) + let opacity = calculateOpacityValue(isLastView) + return opacity + } catch { return 0 } + } +} +private extension NavigationStackView { + func checkOpacityPrerequisites(_ view: AnyNavigatableView) throws { + if !view.isOne(of: stack.views.last, temporaryViews.last, temporaryViews.isNextToLast) { throw "Opacity can concern the last or next to last element of the stack" } + } + func isLastView(_ view: AnyNavigatableView) -> Bool { + let lastView = stack.transitionType == .push ? temporaryViews.last : stack.views.last + return view == lastView + } + func calculateOpacityValue(_ isLastView: Bool) -> CGFloat { + isLastView ? animatableOpacity : 1 - animatableOpacity + } +} + +// MARK: - Calculating Offset +private extension NavigationStackView { + func getOffset(_ view: AnyNavigatableView) -> CGSize { + do { + try checkOffsetPrerequisites(view) + + let offsetSlideValue = calculateSlideOffsetValue(view) + let offset = animatableOffset + offsetSlideValue + let offsetX = calculateXOffsetValue(offset), offsetY = calculateYOffsetValue(offset) + return .init(width: offsetX, height: offsetY) + } catch { return .zero } + } +} +private extension NavigationStackView { + func checkOffsetPrerequisites(_ view: AnyNavigatableView) throws { + if !stack.transitionAnimation.isOne(of: .horizontalSlide, .verticalSlide) { throw "Offset cannot be set for a non-slide type" } + if !view.isOne(of: stack.views.last, temporaryViews.last, temporaryViews.isNextToLast) { throw "Offset can concern the last or next to last element of the stack" } + } + func calculateSlideOffsetValue(_ view: AnyNavigatableView) -> CGFloat { + switch view == temporaryViews.last { + case true: return stack.transitionType == .push ? 0 : maxOffsetValue + case false: return stack.transitionType == .push ? -maxOffsetValue : 0 + } + } + func calculateXOffsetValue(_ offset: CGFloat) -> CGFloat { stack.transitionAnimation == .horizontalSlide ? offset : 0 } + func calculateYOffsetValue(_ offset: CGFloat) -> CGFloat { stack.transitionAnimation == .verticalSlide ? offset : 0 } +} + +// MARK: - On Transition Begin +private extension NavigationStackView { + func onViewsChanged(_ views: [AnyNavigatableView]) { + blockTransitions() + updateTemporaryViews(views) + resetOffsetAndOpacity() + animateOffsetAndOpacityChange() + } +} +private extension NavigationStackView { + func blockTransitions() { + NavigationManager.blockTransitions(true) + } + func updateTemporaryViews(_ views: [AnyNavigatableView]) { + if stack.transitionType == .push { temporaryViews = views } + } + func resetOffsetAndOpacity() { + let animatableOffsetFactor = stack.transitionType == .push ? 1.0 : -1.0 + + animatableOffset = maxOffsetValue * animatableOffsetFactor + animatableOpacity = 0 + } + func animateOffsetAndOpacityChange() { withAnimation(animation) { + animatableOffset = 0 + animatableOpacity = 1 + }} +} + +// MARK: - On Transition End +private extension NavigationStackView { + func onAnimationCompleted() { + resetViewOnAnimationCompleted() + unblockTransitions() + } +} +private extension NavigationStackView { + func unblockTransitions() { + NavigationManager.blockTransitions(false) + } + func resetViewOnAnimationCompleted() { + if stack.transitionType == .pop { + temporaryViews = stack.views + animatableOffset = -maxOffsetValue + } + } +} + +// MARK: - Configurables +private extension NavigationStackView { + var maxOffsetValue: CGFloat { [.horizontalSlide: UIScreen.width, .verticalSlide: UIScreen.height][stack.transitionAnimation] ?? 0 } + var animation: Animation { stack.transitionAnimation == .no ? .easeInOut(duration: 0) : .spring(response: 0.44, dampingFraction: 1, blendDuration: 0.4) } +} diff --git a/Sources/Navigattie/Views/NavigationView.swift b/Sources/Navigattie/Views/NavigationView.swift new file mode 100644 index 0000000..f0be1af --- /dev/null +++ b/Sources/Navigattie/Views/NavigationView.swift @@ -0,0 +1,18 @@ +// +// NavigationView.swift of Navigattie +// +// Created by Tomasz Kurylik +// - Twitter: https://twitter.com/tkurylik +// - Mail: tomasz.kurylik@mijick.com +// +// Copyright ©2023 Mijick. Licensed under MIT License. + + +import SwiftUI + +struct NavigationView: View { + @Namespace var namespace + + init(rootView: some NavigatableView) { NavigationManager.setRoot(rootView) } + var body: some View { NavigationStackView(namespace: namespace) } +}