Search code examples
swiftuikituitabbarcontrollertransitionios-animations

How to create transition animation from main VC by gesture as Instagram


I want to create a transition from TabBar by gesture left or right as in Instagram, I'll attach a video example here, but can't fully understand how it works, and what I can use for the recreation of this example. What I should use if I want to achieve the same effect? Also will appreciate it if you share an idea of how it is possible to achieve this effect. Video Example Of Animation

I'll try to recteate transition animation from Instagram to related VCs from UITabBar


Solution

  • Last day I had a play with the code and achieve the desired result. My goal was create three different VC that first for camera, second for contacts list and last for main page( this is UITabBar, and he is contained more than one VC but in code this is looks as one ).

    I tried achieve not only visual representation of this affect also tried create three different VC that will exist together and not release from memory during transition between them.

    Between all of the VCs exist the capability to move and not lose results, also exist the capability to send data between if needed, and memory leaks weren’t presented. In this example, I decide not to use the transition animation of VC because transition follows us to open and closed some of VC, and I am not sure that I can achieve the same effect if will use the transition animation of VC.

    Video Example of Result

    Implementation: First of all, I had to divide this task into a few subtasks

    1. Create Camera VC.
    2. Create Contact VC.
    3. Create TabBar VCs.
    4. Create Container VC that will work with transition animation between the above VCs.

    Camera VC

    This VC should contain a shadow view for the animation of opening this VC as on real Instagram, and animation of offset, for this effect I used the coordinate system of the main view. This VC contains setupAnimationEffect and setupOffsetAnimation methods that receive a percentage of opening from ContainerVC, the implementation below.

    Code of Camera VC:

    class CameraVC: UIViewController {
        
        lazy var image: UIImageView = {
            let view = UIImageView()
            view.translatesAutoresizingMaskIntoConstraints = false
            view.image = UIImage(named: "Camera")
            return view
        }()
        
        lazy var shadowView: UIView = {
            let view = UIView()
            view.translatesAutoresizingMaskIntoConstraints = false
            view.backgroundColor = .black
            view.alpha = 1
            return view
        }()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .black
            view.addSubview(image)
            
            image.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
            image.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
            image.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
            image.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
            
            view.addSubview(shadowView)
            shadowView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
            shadowView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
            shadowView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
            shadowView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
            
            let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapGestures(_:)))
            view.isUserInteractionEnabled = true
            view.addGestureRecognizer(tapGesture)
            
        }
        
        
        @objc func tapGestures(_ sender: UITapGestureRecognizer) {
            print("TAP TAP")
        }
        
        func setupAnimationEffect(_ alpha: Double) {
            shadowView.alpha = alpha
            
            let offset = alpha.percent.oneDigit.calculateOffset
            
            var mainFrame = view.bounds
            mainFrame.origin.x = offset
            view.bounds = mainFrame
        }
        
        func setupOffsetAnimation(_ percentOfOpenings: Double, _ direction: ScrollDirection?) {
            
    
            let offset = percentOfOpenings.percent.oneDigit.calculateOffset
            
            // Calculate offset
            var mainFrame = view.bounds
            mainFrame.origin.x = offset
            view.bounds = mainFrame
    
        }
    
        
    }
    

    Contact VC

    This is empty VC for emulation this page, without design, is this example, non of the logic not needed except for presenting.

    Code of Contact VC:

    class ContactListVC: UIViewController, UIGestureRecognizerDelegate {
        
        lazy var button: UIButton = {
            let view = UIButton()
            view.translatesAutoresizingMaskIntoConstraints = false
            view.setTitle("CHANGE COLOR", for: .normal)
            let color = UIColor.black
            view.setTitleColor(color, for: .normal)
            view.addTarget(self, action: #selector(changeColor(_:)), for: .touchUpInside)
            view.backgroundColor = .systemBlue
            return view
        }()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .white
            
            initViews()
        }
        
        private func initViews() {
            let color = UIColor.systemGray.cgColor
            view.layer.borderColor = color
            view.layer.borderWidth = 1
            view.addSubview(button)
            button.heightAnchor.constraint(equalToConstant: 50).isActive = true
            button.widthAnchor.constraint(equalToConstant: 200).isActive = true
            button.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        }
        
        @objc func changeColor(_ sender: UIButton) {
            if (view.backgroundColor == .white) {
                view.backgroundColor = .link
            } else {
                view.backgroundColor = .white
            }
        }
        
    }
    

    TabBar VCs

    UITabBar contains 5 empty tabs with different colors for emulation, one PanGestures for blocking the transition to the left or right side when the user interacts with the tab bar, and the same logic for blocking the transition if a user is not at the first tab, as in Instagram. For sending this signal I used closure and used the delegate method of gestures for enabling a few gestures in one moment because this tab all of the tabs are Childs of ContainerVC.

    Code of TabBar VCs:

    class InstagramTabBar: UITabBarController, UITabBarControllerDelegate, UIGestureRecognizerDelegate {
        
        var indexNotification: ((Int?, Bool) -> Void)?
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            initTabBarSettings()
            initTabBarTabs()
        }
        
        private func initTabBarSettings() {
            delegate = self
            tabBar.backgroundColor = .white
            tabBar.clipsToBounds = true
            tabBar.layer.cornerRadius = 40
            tabBar.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
            
            // Tab bar divider line
            let topBorder = CALayer()
            let borderHeight: CGFloat = 1
            
            topBorder.borderWidth = borderHeight
            topBorder.borderColor = UIColor.systemGray.cgColor
            topBorder.frame = CGRect(x: 0, y: 0, width: tabBar.frame.width, height: borderHeight)
            
            tabBar.layer.addSublayer(topBorder)
            
            // Added gestures for blocking swiping if useer interact with TabBar
            let tapGesture = UIPanGestureRecognizer(target: self, action: #selector(tapGestures(_:)))
            tapGesture.delegate = self
            tabBar.isUserInteractionEnabled = true
            tabBar.addGestureRecognizer(tapGesture)
            
        }
        
        private func initTabBarTabs() {
            let tab1 = Tab1()
            let tab2 = Tab2()
            let tab3 = Tab3()
            let tab4 = Tab4()
            let tab5 = Tab5()
            
            let icon1 = UITabBarItem(title: "Tab 1", image: nil, selectedImage: nil)
            tab1.tabBarItem = icon1
            
            let icon2 = UITabBarItem(title: "Tab 2", image: nil, selectedImage: nil)
            tab2.tabBarItem = icon2
            
            let icon3 = UITabBarItem(title: "Tab 3", image: nil, selectedImage: nil)
            tab3.tabBarItem = icon3
            
            let icon4 = UITabBarItem(title: "Tab 4", image: nil, selectedImage: nil)
            tab4.tabBarItem = icon4
            
            let icon5 = UITabBarItem(title: "Tab 5", image: nil, selectedImage: nil)
            tab5.tabBarItem = icon5
            
            let controllers = [tab1, tab2, tab3, tab4, tab5]
            viewControllers = controllers
            
        }
        
        func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
            true
        }
        
        @objc func tapGestures(_ sender: UIPanGestureRecognizer) {
            if (sender.state == .ended) {
                indexNotification?(selectedIndex, true)
            } else {
                indexNotification?(selectedIndex, false)
            }
        }
        
        func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
    let selectedIndex = tabBarController.viewControllers?.firstIndex(of: viewController)!
            let isScroll = (selectedIndex == 0) ? true : false
        indexNotification?(Int(selectedIndex ?? 0), isScroll)
        }
    }
        class Tab1: UIViewController {
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .white
            
        }
        
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
            // used here because first time launch show grey background for corners
            initViewSettings()
        }
        
        private func initViewSettings() {
            view.layer.cornerRadius = 40
            view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMinXMinYCorner]
        }
    }
    
    class Tab2: UIViewController {
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .blue
        }
    }
    
    class Tab3: UIViewController {
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .purple
        }
    }
    
    class Tab4: UIViewController {
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .green
        }
    }
    
    class Tab5: UIViewController {
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .yellow
        }
    }
    

    And last but not least step, Main Container

    Main Container handles all signals from UITabBar and works with transition effects between all VCs. All VCs as CameraVC, ContactsVC, and TabBarVCs, are children of this VC. For creating these effects was used UIScrollView which holds all these VCs. All these VCs installed to UIScrollView as a normal view on the appropriate order as in the real Instagram app, and constraints initialization is absolutely the same as with normal views, except CameraVC, because the transition of opening this view not looks as simple paging, TabBar view does should be above of this VC. Therefore CameraVC is installed to UIScrollView but the constraints to Main Container view. To UIScrollView was added offset that is equal to the width of Container View.

    For blocking scrolling was existed a method that receives all these parameters from the layer above and init appropriated setting to UIScrollView.

    For detecting when appropriated tab should be open and initialization of animation I worked with the offset of UIScrollView, for this manipulation used the delegate method of UIScrollView . Inside this method based on the offset of UIScrollView, I calculated the progress of opening Camera VC, used these methods for detecting the progress of opening and shadow, then sent this data to Camera VC and enable these effects.

    From my perspective of vision, this approach to this situation is easier and better than creating transition animation of VCs if needed follows absolutely the same approach as in a real Instagram App. Will be glad to hear the weak side of this approach to solving this problem.

    Code of Main Container VC:

    class MainContainerVC: UIViewController, UIScrollViewDelegate {
        
        private let main = InstagramTabBar()
        private let camera = CameraVC()
        private let contactList = ContactListVC()
        
        private var lastVelocityXSign = 0
        private var barStyle = UIStatusBarStyle.lightContent
        private var openedVCIndex: Int = 1 {
            didSet {
                updateStatusBarColor()
            }
        }
        
        private lazy var containerScrollView: UIScrollView = {
            let view = UIScrollView()
            view.translatesAutoresizingMaskIntoConstraints = false
            view.backgroundColor = .clear
            view.isPagingEnabled = true
            view.bounces = false
            view.isUserInteractionEnabled = true
            view.isScrollEnabled = true
            view.showsHorizontalScrollIndicator = false
            view.delegate = self
            return view
        }()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .clear
            installView()
            connectTabBarIndexDetector()
        }
        
        override var preferredStatusBarStyle: UIStatusBarStyle {
            return barStyle
        }
        
        private func installView() {
            view.addSubview(containerScrollView)
            
            containerScrollView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
            containerScrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
            containerScrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
            containerScrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
            
            containerScrollView.contentSize = CGSize(width: 3 * view.frame.width, height: containerScrollView.frame.height)
            
            addChild(camera)
            camera.didMove(toParent: self)
            containerScrollView.addSubview(camera.view)
            camera.view.translatesAutoresizingMaskIntoConstraints = false
            camera.view.topAnchor.constraint(equalTo: view.topAnchor, constant: 0).isActive = true
            camera.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
            camera.view.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0).isActive = true
            camera.view.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
            camera.view.widthAnchor.constraint(equalToConstant: view.frame.width).isActive = true
            
            addChild(main)
            main.didMove(toParent: self)
            containerScrollView.addSubview(main.view)
            main.view.translatesAutoresizingMaskIntoConstraints = false
            main.view.topAnchor.constraint(equalTo: containerScrollView.topAnchor, constant: 0).isActive = true
            main.view.bottomAnchor.constraint(equalTo: containerScrollView.bottomAnchor).isActive = true
            main.view.leadingAnchor.constraint(equalTo: containerScrollView.leadingAnchor, constant: view.frame.width).isActive = true
            main.view.widthAnchor.constraint(equalToConstant: view.frame.width).isActive = true
            main.view.centerYAnchor.constraint(equalTo: containerScrollView.centerYAnchor).isActive = true
            
            addChild(contactList)
            contactList.didMove(toParent: self)
            containerScrollView.addSubview(contactList.view)
            contactList.view.translatesAutoresizingMaskIntoConstraints = false
            contactList.view.topAnchor.constraint(equalTo: containerScrollView.topAnchor, constant: 0).isActive = true
            contactList.view.bottomAnchor.constraint(equalTo: containerScrollView.bottomAnchor).isActive = true
            contactList.view.leadingAnchor.constraint(equalTo: main.view.trailingAnchor).isActive = true
            contactList.view.trailingAnchor.constraint(equalTo: containerScrollView.trailingAnchor).isActive = true
            contactList.view.widthAnchor.constraint(equalToConstant: view.frame.width).isActive = true
            contactList.view.centerYAnchor.constraint(equalTo: containerScrollView.centerYAnchor).isActive = true
            
            // DefaultPosition Index 1
            containerScrollView.setContentOffset(CGPoint(x: view.frame.width, y: 0), animated: true)
            
        }
        
        private func connectTabBarIndexDetector() {
            main.indexNotification = { [weak self] index, end in
                guard index != nil else {
                    self?.containerScrollView.isScrollEnabled = false
                    return
                }
                if (index! == 0) {
                    if (end == true) {
                        self?.containerScrollView.isScrollEnabled = true
                    } else {
                        self?.containerScrollView.isScrollEnabled = false
                    }
                    
                } else {
                    self?.containerScrollView.isScrollEnabled = false
                }
            }
        }
        
        private func setOpenedIndex(_ offset: Double) {
            let position0 = 0.0
            let position1 = view.frame.width
            let position2 = (view.frame.width * 2)
            
            switch offset {
            case position0:
                openedVCIndex = 0
            case position1:
                openedVCIndex = 1
            case position2:
                openedVCIndex = 2
            default:
                break
            }
        }
        
        private func updateStatusBarColor() {
            if (openedVCIndex == 0) {
                barStyle = UIStatusBarStyle.lightContent
            } else {
                barStyle = UIStatusBarStyle.darkContent
            }
            
            setNeedsStatusBarAppearanceUpdate()
        }
        
        func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
            setOpenedIndex(scrollView.contentOffset.x)
        }
        
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            let percent = Double(progressAlongAxis(scrollView.contentOffset.x , view.frame.height / 2)).twoDigits
    
            camera.setupAnimationEffect(percent)
    
        }
        
        
        private func progressAlongAxis(_ pointOnAxis: CGFloat, _ axisLength: CGFloat) -> CGFloat {
            let movementOnAxis = pointOnAxis / axisLength
            let positiveMovementOnAxis = fmaxf(Float(movementOnAxis), 0.0)
            let positiveMovementOnAxisPercent = fminf(positiveMovementOnAxis, 1.0)
            return CGFloat(positiveMovementOnAxisPercent)
        }
        
    }
    

    Rest of the code that used for calculations

    extension UIView {
        func roundCorners(corners: UIRectCorner, radius: CGFloat) {
            let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
            let mask = CAShapeLayer()
            mask.path = path.cgPath
            layer.mask = mask
        }
    }
    
    extension Double {
        var threeDigits: Double {
            return (self * 1000).rounded(.toNearestOrEven) / 1000
        }
        
        var twoDigits: Double {
            return (self * 100).rounded(.toNearestOrEven) / 100
        }
        
        var oneDigit: Double {
            return (self * 10).rounded(.toNearestOrEven) / 10
        }
        
        var percent: Double {
            return self / 1.0 * 100
        }
        
        // 0.54, because 100 / 50( 50 this is deffault offset ) == 0.5, during testing I observe that max == 92 not a 100, because 92 / 50 == 0.54
        var calculateOffset: Double {
            return self * 0.54
        }
    }
    
    enum ScrollDirection {
        case Left
        case Right
    }