0

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

Ice
  • 680
  • 1
  • 10
  • 23
  • 1
    https://developer.apple.com/documentation/uikit/uitabbarcontrollerdelegate/1621167-tabbarcontroller there are two custom transition callbacks on uitabbarcontrollerdelegate, start from there – glotcha Nov 02 '22 at 08:52

1 Answers1

0

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
}

Ice
  • 680
  • 1
  • 10
  • 23