Skip to content

Commit

Permalink
Patch 1.1.0
Browse files Browse the repository at this point in the history
feat:
- Added the possibility to return to the previous view using the drag gesture (#31)

style:
- Improved scale animation
  • Loading branch information
FulcrumOne authored Jun 28, 2024
1 parent 2720753 commit e58274a
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 45 deletions.
2 changes: 1 addition & 1 deletion MijickNavigationView.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Pod::Spec.new do |s|
NavigationView is a free and open-source library dedicated for SwiftUI that makes navigation easier and much cleaner.
DESC

s.version = '1.0.0'
s.version = '1.1.0'
s.ios.deployment_target = '15.0'
s.swift_version = '5.0'

Expand Down
11 changes: 11 additions & 0 deletions Sources/Internal/Managers/NavigationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public class NavigationManager: ObservableObject {
private(set) var transitionsBlocked: Bool = false { didSet { onTransitionsBlockedUpdate() } }
private(set) var transitionType: TransitionType = .push
private(set) var transitionAnimation: TransitionAnimation = .no
private(set) var navigationBackGesture: NavigationBackGesture = .no

static let shared: NavigationManager = .init()
private init() {}
Expand All @@ -32,13 +33,23 @@ extension NavigationManager {
static func setRoot(_ rootView: some NavigatableView) { DispatchQueue.main.async { shared.views = [.init(rootView, .no)] }}
static func replaceRoot(_ newRootView: some NavigatableView) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { shared.transitionType = .replaceRoot(.init(newRootView, .no)) }}
static func blockTransitions(_ value: Bool) { shared.transitionsBlocked = value }
static func setTransitionType(_ value: TransitionType) { shared.transitionType = value }
}

// MARK: - Gesture Handlers
extension NavigationManager {
func gestureStarted() {
transitionAnimation = views.last?.animation ?? .no
navigationBackGesture = views.last?.configure(view: .init()).navigationBackGesture ?? .no
}
}

// MARK: - On Attributes Will/Did Change
private extension NavigationManager {
func onViewsWillUpdate(_ newValue: [AnyNavigatableView]) { if newValue.count != views.count {
transitionType = newValue.count > views.count || !transitionType.isOne(of: .push, .pop) ? .push : .pop
transitionAnimation = (transitionType == .push ? newValue.last?.animation : views[newValue.count].animation) ?? .no
navigationBackGesture = newValue.last?.configure(view: .init()).navigationBackGesture ?? .no
}}
func onTransitionsBlockedUpdate() { if !transitionsBlocked, case let .replaceRoot(newRootView) = transitionType {
views = views.appendingAsFirstAndRemovingDuplicates(newRootView)
Expand Down
161 changes: 117 additions & 44 deletions Sources/Internal/Views/NavigationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,18 @@ struct NavigationView: View {
let config: NavigationGlobalConfig
@ObservedObject private var stack: NavigationManager = .shared
@ObservedObject private var screenManager: ScreenManager = .shared
@GestureState private var isGestureActive: Bool = false
@State private var temporaryViews: [AnyNavigatableView] = []
@State private var animatableData: AnimatableData = .init()
@State private var gestureData: GestureData = .init()


var body: some View {
ZStack { ForEach(temporaryViews, id: \.id, content: createItem) }
.ignoresSafeArea(.container)
.gesture(createDragGesture())
.onChange(of: stack.views, perform: onViewsChanged)
.onChange(of: isGestureActive, perform: onDragGestureEnded)
.onAnimationCompleted(for: animatableData.opacity, perform: onAnimationCompleted)
}
}
Expand All @@ -38,9 +42,54 @@ private extension NavigationView {
.offset(x: getRotationTranslation(item))
.rotation3DEffect(getRotationAngle(item), axis: getRotationAxis(), anchor: getRotationAnchor(item), perspective: getRotationPerspective())
.compositingGroup()
.disabled(gestureData.isActive)
}
}

// MARK: - Handling Drag Gesture
private extension NavigationView {
func createDragGesture() -> some Gesture { DragGesture()
.updating($isGestureActive) { _, state, _ in state = true }
.onChanged(onDragGestureChanged)
}
}
private extension NavigationView {
func onDragGestureChanged(_ value: DragGesture.Value) { guard canUseDragGesture() else { return }
updateAttributesOnDragGestureStarted()
gestureData.translation = calculateNewDragGestureDataTranslation(value)
}
func onDragGestureEnded(_ value: Bool) { guard !value, canUseDragGesture() else { return }
switch shouldDragGestureReturn() {
case true: onDragGestureEndedWithReturn()
case false: onDragGestureEndedWithoutReturn()
}
}
}
private extension NavigationView {
func canUseDragGesture() -> Bool {
guard stack.views.count > 1 else { return false }
guard !stack.transitionsBlocked else { return false }
guard stack.navigationBackGesture == .drag else { return false }
return true
}
func updateAttributesOnDragGestureStarted() { guard !gestureData.isActive else { return }
stack.gestureStarted()
gestureData.isActive = true
}
func calculateNewDragGestureDataTranslation(_ value: DragGesture.Value) -> CGFloat { switch stack.transitionAnimation {
case .horizontalSlide, .cubeRotation, .scale: max(value.translation.width, 0)
case .verticalSlide: max(value.translation.height, 0)
default: 0
}}
func shouldDragGestureReturn() -> Bool { gestureData.translation > screenManager.size.width * 0.1 }
func onDragGestureEndedWithReturn() { NavigationManager.pop() }
func onDragGestureEndedWithoutReturn() { withAnimation(getAnimation()) {
NavigationManager.setTransitionType(.push)
gestureData.isActive = false
gestureData.translation = 0
}}
}

// MARK: - Local Configurables
private extension NavigationView {
func getTopPadding(_ item: AnyNavigatableView) -> CGFloat { switch getConfig(item).ignoredSafeAreas {
Expand All @@ -60,53 +109,56 @@ private extension NavigationView {
func getOpacity(_ view: AnyNavigatableView) -> CGFloat { guard canCalculateOpacity(view) else { return 0 }
let isLastView = isLastView(view)
let opacity = calculateOpacityValue(isLastView)
let finalOpacity = calculateFinalOpacityValue(opacity)
return finalOpacity
return opacity
}
}
private extension NavigationView {
func canCalculateOpacity(_ view: AnyNavigatableView) -> Bool {
if !view.isOne(of: temporaryViews.last, temporaryViews.nextToLast) { return false }
guard view.isOne(of: temporaryViews.last, temporaryViews.nextToLast) else { return false }
return true
}
func isLastView(_ view: AnyNavigatableView) -> Bool {
let lastView = stack.transitionType == .push ? temporaryViews.last : stack.views.last
return view == lastView
}
func calculateOpacityValue(_ isLastView: Bool) -> CGFloat {
isLastView ? animatableData.opacity : 1 - animatableData.opacity
}
func calculateFinalOpacityValue(_ opacity: CGFloat) -> CGFloat { switch stack.transitionAnimation {
case .no, .dissolve, .scale: opacity
case .horizontalSlide, .verticalSlide, .cubeRotation: stack.transitionsBlocked ? 1 : opacity
func calculateOpacityValue(_ isLastView: Bool) -> CGFloat { switch stack.transitionAnimation {
case .no, .horizontalSlide, .verticalSlide, .cubeRotation: 1
case .dissolve: isLastView ? animatableData.opacity : 1 - animatableData.opacity
case .scale: calculateOpacityValueForScaleTransition(isLastView)
}}
}
private extension NavigationView {
func calculateOpacityValueForScaleTransition(_ isLastView: Bool) -> CGFloat { switch isLastView {
case true: gestureData.isActive ? 1 - gestureProgress * 1.5 : 1
case false: gestureData.isActive ? 1 : 1 - animatableData.opacity * 1.5
}}
}

// MARK: - Calculating Offset
private extension NavigationView {
func getOffset(_ view: AnyNavigatableView) -> CGSize { guard canCalculateOffset(view) else { return .zero }
let offsetSlideValue = calculateSlideOffsetValue(view)
let offset = animatableData.offset + offsetSlideValue
let offset = animatableData.offset + offsetSlideValue + gestureData.translation
let offsetX = calculateXOffsetValue(offset), offsetY = calculateYOffsetValue(offset)
let finalOffset = calculateFinalOffsetValue(view, offsetX, offsetY)
return finalOffset
}
}
private extension NavigationView {
func canCalculateOffset(_ view: AnyNavigatableView) -> Bool {
if !stack.transitionAnimation.isOne(of: .horizontalSlide, .verticalSlide) { return false }
if !view.isOne(of: temporaryViews.last, temporaryViews.nextToLast) { return false }
guard stack.transitionAnimation.isOne(of: .horizontalSlide, .verticalSlide) || stack.navigationBackGesture == .drag else { return false }
guard view.isOne(of: temporaryViews.last, temporaryViews.nextToLast) else { return false }
return true
}
func calculateSlideOffsetValue(_ view: AnyNavigatableView) -> CGFloat { switch view == temporaryViews.last {
case true: stack.transitionType == .push ? 0 : maxOffsetValue
case false: stack.transitionType == .push ? -maxOffsetValue : 0
case true: stack.transitionType == .push || gestureData.isActive ? 0 : maxOffsetValue
case false: stack.transitionType == .push || gestureData.isActive ? -maxOffsetValue : 0
}}
func calculateXOffsetValue(_ offset: CGFloat) -> CGFloat { stack.transitionAnimation == .horizontalSlide ? offset : 0 }
func calculateYOffsetValue(_ offset: CGFloat) -> CGFloat { stack.transitionAnimation == .verticalSlide ? offset : 0 }
func calculateFinalOffsetValue(_ view: AnyNavigatableView, _ offsetX: CGFloat, _ offsetY: CGFloat) -> CGSize { switch view == temporaryViews.last {
case true: .init(width: offsetX, height: offsetY)
case false: .init(width: max(offsetX, -maxXOffsetValueWhileRemoving), height: 0)
case false: .init(width: offsetX * offsetXFactor, height: 0)
}}
}

Expand All @@ -120,15 +172,15 @@ private extension NavigationView {
}
private extension NavigationView {
func canCalculateScale(_ view: AnyNavigatableView) -> Bool {
if !stack.transitionAnimation.isOne(of: .scale) { return false }
if !view.isOne(of: temporaryViews.last, temporaryViews.nextToLast) { return false }
guard stack.transitionAnimation.isOne(of: .scale) else { return false }
guard view.isOne(of: temporaryViews.last, temporaryViews.nextToLast) else { return false }
return true
}
func calculateScaleValue(_ view: AnyNavigatableView) -> CGFloat { switch view == temporaryViews.last {
case true: stack.transitionType == .push ? 1 - scaleFactor + animatableData.scale : 1 - animatableData.scale
case false: stack.transitionType == .push ? 1 + animatableData.scale : 1 + scaleFactor - animatableData.scale
case true: stack.transitionType == .push && !gestureData.isActive ? 1 - scaleFactor + animatableData.scale : 1 - animatableData.scale * (gestureProgress == 0 ? 1 : gestureProgress)
case false: stack.transitionType == .push || gestureData.isActive ? 1 - animatableData.scale * (gestureProgress - 1) : 1 + scaleFactor - animatableData.scale
}}
func calculateFinalScaleValue(_ scaleValue: CGFloat) -> CGFloat { stack.transitionsBlocked ? scaleValue : 1 }
func calculateFinalScaleValue(_ scaleValue: CGFloat) -> CGFloat { stack.transitionsBlocked || gestureData.translation > 0 ? scaleValue : 1 }
}

// MARK: - Calculating Rotation
Expand All @@ -153,26 +205,29 @@ private extension NavigationView {
}
private extension NavigationView {
func canCalculateRotation(_ view: AnyNavigatableView) -> Bool {
if !stack.transitionAnimation.isOne(of: .cubeRotation) { return false }
if !view.isOne(of: temporaryViews.last, temporaryViews.nextToLast) { return false }
guard stack.transitionAnimation.isOne(of: .cubeRotation) else { return false }
guard view.isOne(of: temporaryViews.last, temporaryViews.nextToLast) else { return false }
return true
}
func calculateRotationAngleValue(_ view: AnyNavigatableView) -> Angle { switch view == temporaryViews.last {
case true: .degrees(90 + -animatableData.rotation * 90)
case false: .degrees(-animatableData.rotation * 90)
}}
func calculateRotationTranslationValue(_ view: AnyNavigatableView) -> CGFloat { switch view == temporaryViews.last {
case true: screenManager.size.width - (animatableData.rotation * screenManager.size.width)
case false: -1 * (animatableData.rotation * screenManager.size.width)
}}
func calculateRotationAngleValue(_ view: AnyNavigatableView) -> Angle { let rotationFactor = gestureData.isActive ? 1 - gestureProgress : animatableData.rotation
switch view == temporaryViews.last {
case true: return .degrees(90 - 90 * rotationFactor)
case false: return .degrees(-90 * rotationFactor)
}
}
func calculateRotationTranslationValue(_ view: AnyNavigatableView) -> CGFloat { let rotationFactor = gestureData.isActive ? 1 - gestureProgress : animatableData.rotation
switch view == temporaryViews.last {
case true: return screenManager.size.width - rotationFactor * screenManager.size.width
case false: return -rotationFactor * screenManager.size.width
}
}
}

// MARK: - Animation
private extension NavigationView {
func getAnimation() -> Animation { switch stack.transitionAnimation {
case .no: .easeInOut(duration: 0)
case .dissolve, .horizontalSlide, .verticalSlide: .spring(duration: 0.36, bounce: 0, blendDuration: 0.1)
case .scale: .snappy
case .dissolve, .horizontalSlide, .verticalSlide, .scale: .interpolatingSpring(mass: 3, stiffness: 1000, damping: 500, initialVelocity: 6.4)
case .cubeRotation: .easeOut(duration: 0.52)
}}
}
Expand All @@ -197,18 +252,26 @@ private extension NavigationView {
func resetOffsetAndOpacity() {
let animatableOffsetFactor = stack.transitionType == .push ? 1.0 : -1.0

animatableData.offset = maxOffsetValue * animatableOffsetFactor
animatableData.opacity = 0
animatableData.rotation = stack.transitionType == .push ? 0 : 1
animatableData.scale = 0
animatableData.offset = maxOffsetValue * animatableOffsetFactor + gestureData.translation
animatableData.opacity = gestureProgress
animatableData.rotation = calculateNewRotationOnReset()
animatableData.scale = scaleFactor * gestureProgress
gestureData.isActive = false
gestureData.translation = 0
}
func animateOffsetAndOpacityChange() { withAnimation(getAnimation()) {
animatableData.offset = 0
animatableData.opacity = 1
animatableData.rotation = 1 - animatableData.rotation
animatableData.rotation = stack.transitionType == .push ? 1 : 0
animatableData.scale = scaleFactor
}}
}
private extension NavigationView {
func calculateNewRotationOnReset() -> CGFloat { switch gestureData.isActive {
case true: 1 - gestureProgress
case false: stack.transitionType == .push ? 0 : 1
}}
}

// MARK: - On Transition End
private extension NavigationView {
Expand All @@ -221,19 +284,23 @@ private extension NavigationView {
func unblockTransitions() {
NavigationManager.blockTransitions(false)
}
func resetViewOnAnimationCompleted() {
if stack.transitionType == .pop {
temporaryViews = stack.views
animatableData.offset = -maxOffsetValue
animatableData.rotation = 1
}
func resetViewOnAnimationCompleted() { guard stack.transitionType == .pop else { return }
temporaryViews = stack.views
animatableData.offset = -maxOffsetValue
animatableData.rotation = 1
gestureData.translation = 0
}
}

// MARK: - Helpers
private extension NavigationView {
var gestureProgress: CGFloat { gestureData.translation / (stack.transitionAnimation == .verticalSlide ? screenManager.size.height : screenManager.size.width) }
}

// MARK: - Configurables
private extension NavigationView {
var scaleFactor: CGFloat { 0.46 }
var maxXOffsetValueWhileRemoving: CGFloat { screenManager.size.width * 0.33 }
var offsetXFactor: CGFloat { 1/3 }
var maxOffsetValue: CGFloat { [.horizontalSlide: screenManager.size.width, .verticalSlide: screenManager.size.height][stack.transitionAnimation] ?? 0 }
}

Expand All @@ -245,3 +312,9 @@ fileprivate struct AnimatableData {
var rotation: CGFloat = 0
var scale: CGFloat = 0
}

// MARK: - Gesture Data
fileprivate struct GestureData {
var translation: CGFloat = 0
var isActive: Bool = false
}
15 changes: 15 additions & 0 deletions Sources/Public/Public+NavigationBackGesture.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// Public+NavigationBackGesture.swift of NavigationView
//
// Created by Tomasz Kurylik
// - Twitter: https://twitter.com/tkurylik
// - Mail: [email protected]
// - GitHub: https://github.com/FulcrumOne
//
// Copyright ©2024 Mijick. Licensed under MIT License.


public enum NavigationBackGesture {
case no
case drag
}
3 changes: 3 additions & 0 deletions Sources/Public/Public+NavigationConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ public extension NavigationConfig {
/// Changes the background colour of the selected view
func backgroundColour(_ value: Color) -> Self { changing(path: \.backgroundColour, to: value) }

/// Changes the gesture that can be used to move to the previous view
func navigationBackGesture(_ value: NavigationBackGesture) -> Self { changing(path: \.navigationBackGesture, to: value) }
}

// MARK: - Internal
public struct NavigationConfig: Configurable {
private(set) var ignoredSafeAreas: VerticalEdge.Set? = nil
private(set) var backgroundColour: Color? = nil
private(set) var navigationBackGesture: NavigationBackGesture = .no
}

0 comments on commit e58274a

Please sign in to comment.