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
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.
Implementation: First of all, I had to divide this task into a few subtasks
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
}
}
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
}
}
}
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
}
}
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)
}
}
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
}