diff --git a/Sources/iOS/BottomNavigationController.swift b/Sources/iOS/BottomNavigationController.swift index b84dcf18f..cac058fd4 100644 --- a/Sources/iOS/BottomNavigationController.swift +++ b/Sources/iOS/BottomNavigationController.swift @@ -29,6 +29,7 @@ */ import UIKit +import Motion extension UIViewController { /** @@ -51,18 +52,30 @@ private class MaterialTabBar: UITabBar { } open class BottomNavigationController: UITabBarController { - /// A Boolean that indicates if the swipe feature is enabled.. - open var isSwipeEnabled = false { + /// A Boolean that controls if the swipe feature is enabled. + open var isSwipeEnabled = true { didSet { guard isSwipeEnabled else { - removeSwipeGestureRecognizers() + removeSwipeGesture() return } - prepareSwipeGestureRecognizers() + prepareSwipeGesture() } } + /** + A UIPanGestureRecognizer property internally used for the interactive + swipe. + */ + public private(set) var interactiveSwipeGesture: UIPanGestureRecognizer? + + /** + A private integer for storing index of current view controller + during interactive transition. + */ + private var currentIndex = -1 + /** An initializer that initializes the object with a NSCoder object. - Parameter aDecoder: A NSCoder instance. @@ -151,6 +164,97 @@ open class BottomNavigationController: UITabBarController { view.contentScaleFactor = Screen.scale prepareTabBar() + isSwipeEnabled = true + isMotionEnabled = true + } +} + +private extension BottomNavigationController { + /** + A target method contolling interactive swipe transition based on + gesture recognizer. + - Parameter _ gesture: A UIPanGestureRecognizer. + */ + @objc + func handleTransitionPan(_ gesture: UIPanGestureRecognizer) { + guard selectedIndex != NSNotFound else { + return + } + + let translationX = gesture.translation(in: nil).x + let velocityX = gesture.velocity(in: nil).x + + switch gesture.state { + case .began, .changed: + let isSlidingLeft = currentIndex == -1 ? velocityX < 0 : translationX < 0 + + if currentIndex == -1 { + currentIndex = selectedIndex + } + + let nextIndex = currentIndex + (isSlidingLeft ? 1 : -1) + + if selectedIndex != nextIndex { + /// 5 point threshold + guard abs(translationX) > 5 else { + return + } + + if currentIndex != selectedIndex { + MotionTransition.shared.cancel(isAnimated: false) + } + + guard canSelect(at: nextIndex) else { + return + } + + selectedIndex = nextIndex + MotionTransition.shared.setCompletionCallbackForNextTransition { [weak self] isFinishing in + guard let `self` = self, isFinishing else { + return + } + + self.delegate?.tabBarController?(self, didSelect: self.viewControllers![nextIndex]) + } + } else { + let progress = abs(translationX / view.bounds.width) + MotionTransition.shared.update(Double(progress)) + } + + default: + let progress = (translationX + velocityX) / view.bounds.width + + let isUserHandDirectionLeft = progress < 0 + let isTargetHandDirectionLeft = selectedIndex > currentIndex + + if isUserHandDirectionLeft == isTargetHandDirectionLeft && abs(progress) > 0.5 { + MotionTransition.shared.finish() + } else { + MotionTransition.shared.cancel() + } + + currentIndex = -1 + } + } + + /// Prepares interactiveSwipeGesture. + func prepareSwipeGesture() { + guard nil == interactiveSwipeGesture else { + return + } + + interactiveSwipeGesture = UIPanGestureRecognizer(target: self, action: #selector(handleTransitionPan)) + view.addGestureRecognizer(interactiveSwipeGesture!) + } + + /// Removes interactiveSwipeGesture. + func removeSwipeGesture() { + guard let v = interactiveSwipeGesture else { + return + } + + view.removeGestureRecognizer(v) + interactiveSwipeGesture = nil } } @@ -167,29 +271,29 @@ private extension BottomNavigationController { private extension BottomNavigationController { /** - Selects a view controller at a given index. + Checks if the view controller at a given index can be selected. - Parameter at index: An Int. */ - func select(at index: Int) { + func canSelect(at index: Int) -> Bool { guard index != selectedIndex else { - return + return false } let lastTabIndex = (tabBar.items?.count ?? 1) - 1 guard (0...lastTabIndex).contains(index) else { - return + return false } guard !(index == lastTabIndex && tabBar.items?.last == moreNavigationController.tabBarItem) else { - return + return false } let vc = viewControllers![index] guard delegate?.tabBarController?(self, shouldSelect: vc) != false else { - return + return false } - selectedIndex = index - delegate?.tabBarController?(self, didSelect: vc) + + return true } } @@ -206,43 +310,4 @@ private extension BottomNavigationController { tabBar.backgroundImage = image tabBar.backgroundColor = .white } - - /// Prepare the UISwipeGestureRecognizers. - func prepareSwipeGestureRecognizers() { - removeSwipeGestureRecognizers() - - let right = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeGesture(_:))) - right.direction = .right - view.addGestureRecognizer(right) - - let left = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeGesture(_:))) - left.direction = .left - view.addGestureRecognizer(left) - } - - /// Remove the UISwipeGestureRecognizers. - func removeSwipeGestureRecognizers() { - view.gestureRecognizers?.compactMap { - $0 as? UISwipeGestureRecognizer - }.filter { - .left == $0.direction || .right == $0.direction - }.forEach { - view.removeGestureRecognizer($0) - } - } -} - -private extension BottomNavigationController { - /** - A UISwipeGestureRecognizer that handles swipes. - - Parameter _ gesture: A UISwipeGestureRecognizer. - */ - @objc - func handleSwipeGesture(_ gesture: UISwipeGestureRecognizer) { - guard selectedIndex != NSNotFound else { - return - } - - select(at: .right == gesture.direction ? selectedIndex - 1 : selectedIndex + 1) - } } diff --git a/Sources/iOS/TabBar.swift b/Sources/iOS/TabBar.swift index 6ce5fa92b..1346f1a9f 100644 --- a/Sources/iOS/TabBar.swift +++ b/Sources/iOS/TabBar.swift @@ -29,6 +29,7 @@ */ import UIKit +import Motion open class TabItem: FlatButton { /// A dictionary of TabItemStates to UIColors for states. @@ -278,6 +279,7 @@ open class TabBar: Bar { didSet { oldValue?.isSelected = false selectedTabItem?.isSelected = true + updateScrollView() } } @@ -574,6 +576,55 @@ extension TabBar { } } +internal extension TabBar { + /** + Starts line transition for the index with the given duration. + - Parameter for index: An Int. + - Parameter duration: A TimeInterval. + */ + func startLineTransition(for index: Int, duration: TimeInterval = 0.35) { + guard let s = selectedTabItem, let currentIndex = tabItems.firstIndex(of: s) else { + return + } + + guard currentIndex != index else { + return + } + + let targetFrame = lineFrame(for: tabItems[index], forMotion: true) + + line.transition(.size(targetFrame.size), + .position(targetFrame.origin), + .duration(duration)) + + line.motionViewTransition.start() + } + + /** + Updates line transition to the given progress value. + - Parameter _ progress: A CGFloat. + */ + func updateLineTransition(_ progress: CGFloat) { + line.motionViewTransition.update(progress) + } + + /** + Finishes line transition. + - Parameter isAnimated: A Boolean indicating if the change should be animated. + */ + func finishLineTransition(isAnimated: Bool = true) { + line.motionViewTransition.finish(isAnimated: isAnimated) + } + + /** + Cancels line transition. + - Parameter isAnimated: A Boolean indicating if the change should be animated. + */ + func cancelLineTransition(isAnimated: Bool = true) { + line.motionViewTransition.cancel(isAnimated: isAnimated) + } +} + fileprivate extension TabBar { /** Removes the tabItem animation handler. diff --git a/Sources/iOS/TabsController.swift b/Sources/iOS/TabsController.swift index 6bd0e9d6e..682a4c6cd 100644 --- a/Sources/iOS/TabsController.swift +++ b/Sources/iOS/TabsController.swift @@ -92,6 +92,24 @@ public protocol TabsControllerDelegate { */ @objc optional func tabsController(tabsController: TabsController, didSelect viewController: UIViewController) + + /** + A delegation method that is executed when the interactive transition to view controller + will be cancelled. + - Parameter tabsController: A TabsController. + - Parameter viewController: A UIViewController. + */ + @objc + optional func tabsController(tabsController: TabsController, willCancelSelecting viewController: UIViewController) + + /** + A delegation method that is executed when the interactive transition to view controller + has been cancelled. + - Parameter tabsController: A TabsController. + - Parameter viewController: A UIViewController. + */ + @objc + optional func tabsController(tabsController: TabsController, didCancelSelecting viewController: UIViewController) } open class TabsController: TransitionController { @@ -110,9 +128,8 @@ open class TabsController: TransitionController { @IBInspectable public let tabBar = TabBar() - - /// A Boolean that indicates if the swipe feature is enabled.. - open var isSwipeEnabled = false { + /// A Boolean that controls if the swipe feature is enabled. + open var isSwipeEnabled = true { didSet { guard isSwipeEnabled else { removeSwipeGesture() @@ -148,6 +165,18 @@ open class TabsController: TransitionController { } } + /** + A UIPanGestureRecognizer property internally used for the interactive + swipe. + */ + public private(set) var interactiveSwipeGesture: UIPanGestureRecognizer? + + /** + A private integer for storing index of target view controller + during interactive transition. + */ + private var targetIndex = -1 + /** An initializer that initializes the object with a NSCoder object. - Parameter aDecoder: A NSCoder instance. @@ -212,36 +241,25 @@ fileprivate extension TabsController { return } - var isAuto = false - - switch motionTransitionType { - case .auto: - switch viewController.motionTransitionType { - case .auto: - isAuto = true - MotionTransition.shared.setAnimationForNextTransition(fvcIndex < tvcIndex ? .slide(direction: .left) : .slide(direction: .right)) - default:break - } - default:break + if case .auto = motionTransitionType, case .auto = viewController.motionTransitionType { + MotionTransition.shared.setAnimationForNextTransition(fvcIndex < tvcIndex ? .slide(direction: .left) : .slide(direction: .right)) } if isTriggeredByUserInteraction { delegate?.tabsController?(tabsController: self, willSelect: viewController) } - super.transition(to: viewController) { [weak self, viewController = viewController, completion = completion] (isFinishing) in + super.transition(to: viewController) { [weak self] (isFinishing) in guard let `self` = self else { return } - if isAuto { - MotionTransition.shared.setAnimationForNextTransition(.auto) - } - completion?(isFinishing) - if isTriggeredByUserInteraction { + if isTriggeredByUserInteraction && isFinishing { self.delegate?.tabsController?(tabsController: self, didSelect: viewController) + } else { + self.delegate?.tabsController?(tabsController: self, didCancelSelecting: viewController) } } } @@ -279,37 +297,90 @@ fileprivate extension TabsController { tabBar.tabItems = tabItems tabBar.selectedTabItem = tabItems[selectedIndex] } +} + +private extension TabsController { + /** + A target method contolling interactive swipe transition based on + gesture recognizer. + - Parameter _ gesture: A UIPanGestureRecognizer. + */ + @objc + func handleTransitionPan(_ gesture: UIPanGestureRecognizer) { + let translationX = gesture.translation(in: nil).x + let velocityX = gesture.velocity(in: nil).x + + switch gesture.state { + case .began, .changed: + let isSlidingLeft = targetIndex == -1 ? velocityX < 0 : translationX < 0 + let nextIndex = selectedIndex + (isSlidingLeft ? 1 : -1) + + guard nextIndex >= 0, nextIndex < viewControllers.count else { + return + } + + if targetIndex != nextIndex { + /// 5 point threshold + guard abs(translationX) > 5 else { + return + } + + if targetIndex != -1 { + delegate?.tabsController?(tabsController: self, willCancelSelecting: viewControllers[targetIndex]) + tabBar.cancelLineTransition(isAnimated: false) + MotionTransition.shared.cancel(isAnimated: false) + } + + if internalSelect(at: nextIndex, isTriggeredByUserInteraction: true, selectTabItem: false) { + tabBar.startLineTransition(for: nextIndex, duration: 0.35) + targetIndex = nextIndex + } + } else { + let progress = abs(translationX / view.bounds.width) + tabBar.updateLineTransition(progress) + MotionTransition.shared.update(Double(progress)) + } + + default: + guard targetIndex != -1 else { + return + } + + let progress = (translationX + velocityX) / view.bounds.width + + let isUserHandDirectionLeft = progress < 0 + let isTargetHandDirectionLeft = targetIndex > selectedIndex + + if isUserHandDirectionLeft == isTargetHandDirectionLeft && abs(progress) > 0.5 { + tabBar.finishLineTransition() + MotionTransition.shared.finish() + } else { + tabBar.cancelLineTransition() + MotionTransition.shared.cancel() + delegate?.tabsController?(tabsController: self, willCancelSelecting: viewControllers[targetIndex]) + } + targetIndex = -1 + } + } - /// Prepare Swipe Gesture. + /// Prepares interactiveSwipeGesture. func prepareSwipeGesture() { - removeSwipeGesture() - - let swipeRight = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeGesture(gesture:))) - swipeRight.direction = .right - view.addGestureRecognizer(swipeRight) + guard nil == interactiveSwipeGesture else { + return + } - let swipeLeft = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeGesture(gesture:))) - swipeLeft.direction = .left - view.addGestureRecognizer(swipeLeft) + interactiveSwipeGesture = UIPanGestureRecognizer(target: self, action: #selector(handleTransitionPan)) + container.addGestureRecognizer(interactiveSwipeGesture!) } -} -fileprivate extension TabsController { - /// Remove Swipe Gesture. + /// Removes interactiveSwipeGesture. func removeSwipeGesture() { - guard let v = view.gestureRecognizers else { + guard let v = interactiveSwipeGesture else { return } - for gesture in v { - guard let recognizer = gesture as? UISwipeGestureRecognizer else { - continue - } - - if .left == recognizer.direction || .right == recognizer.direction { - view.removeGestureRecognizer(recognizer) - } - } + container.removeGestureRecognizer(v) + interactiveSwipeGesture = nil } } @@ -363,30 +434,6 @@ fileprivate extension TabsController { } } -fileprivate extension TabsController { - /** - Handles the swipe gesture. - - Parameter gesture: A UIGestureRecognizer. - */ - @objc - func handleSwipeGesture(gesture: UIGestureRecognizer) { - if let swipeGesture = gesture as? UISwipeGestureRecognizer { - switch swipeGesture.direction { - case .right: - guard (selectedIndex - 1) >= 0 else { return } - internalSelect(at: selectedIndex - 1, isTriggeredByUserInteraction: true, selectTabItem: true) - - case .left: - guard (selectedIndex + 1) < viewControllers.count else { return } - internalSelect(at: selectedIndex + 1, isTriggeredByUserInteraction: true, selectTabItem: true) - - default: - break - } - } - } -} - extension TabsController { /** Transitions to the view controller that is at the given index. @@ -425,6 +472,7 @@ extension TabsController { } self?.selectedIndex = index + self?.tabBar.selectedTabItem = self?.tabBar.tabItems[index] } return true diff --git a/Sources/iOS/TransitionController.swift b/Sources/iOS/TransitionController.swift index ff9c5e2cb..f49b09fc0 100644 --- a/Sources/iOS/TransitionController.swift +++ b/Sources/iOS/TransitionController.swift @@ -131,25 +131,29 @@ open class TransitionController: ViewController { open func transition(to viewController: UIViewController, completion: ((Bool) -> Void)? = nil) { prepare(viewController: viewController, in: container) - switch motionTransitionType { - case .auto:break - default: - switch viewController.motionTransitionType { - case .auto: - viewController.motionTransitionType = motionTransitionType - default:break - } + if case .auto = viewController.motionTransitionType { + viewController.motionTransitionType = motionTransitionType } view.isUserInteractionEnabled = false - MotionTransition.shared.transition(from: rootViewController, to: viewController, in: container) { [weak self, viewController = viewController, completion = completion] (isFinishing) in + MotionTransition.shared.transition(from: rootViewController, to: viewController, in: container) { [weak self] isFinishing in guard let s = self else { return } + defer { + s.view.isUserInteractionEnabled = true + completion?(isFinishing) + } + + guard isFinishing else { + s.removeViewController(viewController: viewController) + s.removeViewController(viewController: s.rootViewController) + s.prepare(viewController: s.rootViewController, in: s.container) + return + } + s.rootViewController = viewController - s.view.isUserInteractionEnabled = true - completion?(isFinishing) } }