We write UI Snapchat on Swift

Prologue


In one of my projects, it was necessary to make an interface like that in the Dnipro. When a card comes out with information over the image from the camera, smoothly replacing it with a solid color, and just the same in the opposite direction. I personally was especially fascinated by the transition from the camera window to the side card, and with great pleasure I set out to paint back ways to solve this problem.


On the left is the example of the Snepchat, on the right is the example of the application that we are going to create.



Probably the first solution that comes to mind is to adapt UIScrollView, arrange somehow the views on it, use padjination, but, frankly, the scroll was invented to solve completely different tasks, pick up additional animations on it is time consuming, and it does not have the necessary flexibility . Therefore, using it to solve this problem is absolutely not reasonable.


The scroll between the camera window and the side tab is deceptive - it is not a scroll at all, it is an interactive transition between the views belonging to different controllers. The buttons at the bottom of it are ordinary tabs, clicking on which throws us between the controllers.



Thus, the Snipechat uses its own version of the navigation controller of the type UITabBarControllerwith custom interactive transitions.


UIKitincludes two navigation controllers options that allow customizing transitions - this UINavigationControllerand UITabBarController. Both have in their delegates methods navigationController(_:interactionControllerFor:)and, tabBarController(_:interactionControllerFor:)accordingly, which allow us to use our own interactive animation for the transition.


tabBarController (_: interactionControllerFor :)


navigationController (_: interactionControllerFor :)


But I would not want to be limited to the implementation UITabBarControlleror UINavigationController, especially since we cannot control their internal logic. Therefore, I decided to write my similar controller, and now I want to tell and show what came out of it.


Formulation of the problem


Make your own controller-container in which you can switch between child controllers, using interactive animations for transitions, using the standard mechanism as in UITabBarControllerand UINavigationController. We need this standard mechanism to use already written ready-made transition animations UIViewControllerAnimatedTransitioning.


Project preparation


Usually I try to make the modules in separate frameworks, for this I create a new application project, and add an additional target there Cocoa Touch Framework, and then scatter the source code in the project for the appropriate targets. In this way, I have a separate framework with a test debugging application.


We create Single View App.



Product Name and will be our target.



Click on +to add a target.



We choose Cocoa Touch Framework.



We call our framework an appropriate name, Xcode automatically chooses a project for our target and offers to tie the binary right into the application. We agree.



We will not need default Main.storyboardand ViewController.swiftdelete them.



Also, do not forget to delete the value of the Main Interfaceapplication in the tab on the tab General.



Now we go to AppDelegate.swiftand leave only the method of the applicationfollowing content:


funcapplication(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    // Launch our master view controllerlet master = MasterViewController()
    window = UIWindow()
    window?.rootViewController = master
    window?.makeKeyAndVisible()
    returntrue
}

Here we set our controller to the main place so that it appears after the launch screen.


Now create this one MasterViewController. It will relate to the application, so it is important to choose the right target when creating the file.



MasterViewControllerwe will inherit from SnapchatNavigationControllerwhich we will implement later in the framework. Do not forget to specify importour framework. I don’t cite the full controller code here, the gaps are shown with ellipsis ..., I posted the application on GitHub , where you can see all the details. In this controller, we are only interested in the method viewDidLoad()that initializes the background controller with the camera + one transparent controller (main window) + the controller containing the departing card.


import MakingSnapchatNavigation
classMasterViewController: SnapchatNavigationController{
    overridefuncviewDidLoad() {
        super.viewDidLoad()
        // Устанавливаем фонlet camera = CameraViewController()
        setBackground(vc: camera)
        // Создаем массив дочерних контроллеровvar vcs: [UIViewController] = []
        // Первый прозрачный контроллерvar stub = UIViewController()
        stub.view.backgroundColor = .clear
        vcs.append(stub)
        // Второй контроллер, на него добавляем скролл
        stub = UIViewController()
        stub.view.backgroundColor = .clear
        // Создаем скроллlet scroll = UIScrollView()
        stub.view.addSubview(scroll)
        //Конфигурируем скролл
        ...
        // Создаем вьюху, которая будет лежать на скроллеlet content = GradientView()
        //Конфигурируем ее
        ...
        // Добавляем на скролл
        scroll.addSubview(content)
        vcs.append(stub)
        // Сеттим вьюхи в наш контроллер-контейнер
        setViewControllers(vcs: vcs)
    }
}

What's going on here? We create a controller with a camera and set it to background setBackgroundfrom SnapchatNavigationController. This controller contains a camera image that is stretched to the entire view. Then we create an empty transparent controller and add it to the array, it simply passes the image from the camera through it, it will be possible to place controls on it, create another transparent controller, add a scroll to it, add a view inside the scroll with content, add the second controller to array and setit this array with a special method setViewControllersfrom the parent SnapchatNavigationController.


Do not forget to add a request to use the camera in Info.plist


<key>NSCameraUsageDescription</key><string>Need camera for background</string>

On this test application we consider ready, and go to the most interesting - to the implementation of the framework.


Parent controller structure


First we create an empty one SnapchatNavigationController, it is important to choose the right target for it. If everything was done correctly, then the application should be assembled. This project status can be downloaded via the link .


openclassSnapchatNavigationController: UIViewController{
    overrideopenfuncviewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
    // MARK: - Public interface/// Sets view controllers.publicfuncsetViewControllers(vcs: [UIViewController]) {
    }
    /// Sets background view.publicfuncsetBackground(vc: UIViewController) {
    }
}

Now we add the internal components of which the controller will be composed. I don’t cite all the code here, focusing only on the important points.


We set variables for storage of an array of child controllers. Now we rigidly ask them the required number - 2 pieces. In the future, it will be possible to expand the logic of the controller for use with any number of controllers. We also set a variable to hold the current display controller.


privatelet requiredChildrenAmount = 2// MARK: - View controllers/// top child view controllerprivatevar topViewController: UIViewController?/// all children view controllersprivatevar children: [UIViewController] = []

Create views. We need one view for the background, one view for the effect that we want to impose on the background when changing the controller. We also have a container view for the current child controller and a view indicator that will tell the user how to navigate.


// MARK: - Viewsprivatelet backgroundViewContainer = UIView()
privatelet backgroundBlurEffectView: UIVisualEffectView = {
    let backgroundBlurEffect = UIBlurEffect(style: UIBlurEffectStyle.light)
    let backgroundBlurEffectView = UIVisualEffectView(effect: backgroundBlurEffect)
    backgroundBlurEffectView.alpha = 0return backgroundBlurEffectView
}()
/// content view for childrenprivatelet contentViewContainer = UIView()
privatelet swipeIndicatorView = UIView()

In the next block, we set two variables, swipeAnimatoris responsible for the animation, swipeInteractoris responsible for the interactive (the ability to control the animation progress), we always initialize it during the controller loading, so we make force unwrap.


// MARK: - Animation and transitionprivatelet swipeAnimator = AnimatedTransitioning()
privatevar swipeInteractor: CustomSwipeInteractor!

We also set the transformation for the indicator. We shift the indicator by the width of the container + double shift from the edge + width of the indicator itself, so that the indicator is at the opposite end of the container. The width of the container will be known during the operation of the application, so the variable is calculated on the fly.


// MARK: - Animation transformsprivatevar swipeIndicatorViewTransform: CGAffineTransform {
    get {
        return CGAffineTransform(translationX: -contentViewContainer.bounds.size.width + (swipeIndicatorViewXShift * 2) + swipeIndicatorViewWidth, y: 0)
    }
}

At the time of loading the controller, we assign it to the animation self(below we will implement the appropriate protocol), initialize the interaction based on our animation, the course of which it will control. We also appoint ourselves as a delegate. The delegate will respond to the beginning of the user's gesture and either start the animation or cancel it, depending on the state of the controller. Then we add all the views to the main setupViews()page and call it , setting the constraints.


overrideopenfuncviewDidLoad() {
    super.viewDidLoad()
    swipeAnimator.animation = self
    swipeInteractor = CustomSwipeInteractor(with: swipeAnimator)
    swipeInteractor.delegate = self
    view.addSubview(backgroundViewContainer)
    view.addSubview(backgroundBlurEffectView)
    view.addSubview(contentViewContainer)
    view.addSubview(swipeIndicatorView)
    setupViews()
}

Then we proceed to the logic of installing and removing child controllers in the container. Everything is as simple as the Apple documentation. We use the methods prescribed for such operations.


addChildViewController(vc) - add child controller to the current one.


contentViewContainer.addSubview(vc.view) - add the controller view to the view hierarchy.


vc.view.frame = contentViewContainer.bounds- stretch the view to the entire container. Once we use frames here instead of auto layout, we need to resize them each time the controller is resized, we will omit this logic and we will assume that the container will not change dimensions while the application is running.


vc.didMove(toParentViewController: self) - put a point in the operation of adding a child controller.


swipeInteractor.wireTo- we tie the current controller to the user's gestures. Later we will analyze this method.


// MARK: - Private methodsprivatefuncaddChild(vc: UIViewController) {
    addChildViewController(vc)
    contentViewContainer.addSubview(vc.view)
    vc.view.frame = contentViewContainer.bounds
    vc.didMove(toParentViewController: self)
    topViewController = vc
    let goingRight = children.index(of: topViewController!) == 0
    swipeInteractor.wireTo(viewController: topViewController!, edge: goingRight ? .right : .left)
}
privatefuncremoveChild(vc: UIViewController) {
    vc.willMove(toParentViewController: nil)
    vc.view.removeFromSuperview()
    vc.removeFromParentViewController()
    topViewController = nil
}

There are two more methods, the code of which I will not give here: setViewControllersand setBackground. In the method, setViewControllerswe simply set the array of child controllers in the corresponding variable of our controller and call addChildto display one of them on the view. In the method setBackgroundwe do the same as in addChild, only for the background controller.


Controller Container Animation Logic


Total, the basis of our parent controller is:


  • UIView, dividing into two types
    • Containers
    • Ordinary
  • List of child UIViewController
  • Object that controls the animation swipeAnimatortypeAnimatedTransitioning
  • The object that controls the interactive swipeInteractortype animationCustomSwipeInteractor
  • The delegate of the interactive animation course
  • Implementing an animation protocol

Now we analyze the last two points, then move on to the implementation AnimatedTransitioningand CustomSwipeInteractor.


The delegate of the interactive animation course


The delegate consists of only one method panGestureDidStart(rightToLeftSwipe: Bool) -> Boolthat informs the controller about the beginning of the gesture and its direction. In response, he is waiting for information about whether the animation can be considered to have begun.


In the delegate, we check the current order of the controllers to see if we can start the animation in a given direction, and if everything is OK, we run the method transitionwith parameters: the controller from which we are going, the controller to which we are moving, the direction of movement, the interactivity flag ( in the case of falserunning a fixed transition animation).


funcpanGestureDidStart(rightToLeftSwipe: Bool) -> Bool {
    guardlet topViewController = topViewController,
        let fromIndex = children.index(of: topViewController) else {
            returnfalse
    }
    let newIndex = rightToLeftSwipe ? 1 : 0// избыточная проверка - задел на будущееif newIndex > -1 && newIndex < children.count && newIndex != fromIndex {
        transition(from: children[fromIndex], to: children[newIndex], goingRight: rightToLeftSwipe, interactive: true)
        returntrue
    }
    returnfalse
}

Consider immediately the body of the method transition. First of all, we create the animation execution context CustomControllerContext. We will also analyze this class a bit later; it implements the protocol UIViewControllerContextTransitioning. In the case of UINavigationControllerand an UITabBarControllerinstance of the implementation of this protocol is created by the system automatically and its logic is hidden from us, we need to create our own.


let ctx = CustomControllerContext(fromViewController: from,
                                  toViewController: to,
                                  containerView: contentViewContainer,
                                  goingRight: goingRight)
ctx.isAnimated = true
ctx.isInteractive = interactive
ctx.completionBlock = {
    (didComplete: Bool) inif didComplete {
        self.removeChild(vc: from)
        self.addChild(vc: to)
    }
};

Then we just call either fixed or interactive animation. Fixed can be hung in the future on the buttons-tabs navigation between the controllers, in this example, we will not do this.


if interactive {
    // Animate with interaction
    swipeInteractor.startInteractiveTransition(ctx)
} else {
    // Animate without interaction
    swipeAnimator.animateTransition(using: ctx)
}

Animation protocol


The animation protocol TransitionAnimationconsists of 4 methods:


addTo - a method designed to create the correct structure of child views in the container, so that the overlapping of the previous view of the new one takes place according to the animation idea.


/// Setup the views hirearchy for animation.funcaddTo(containerView: UIView, fromView: UIView, toView: UIView, fromLeft: Bool)

prepare - a method called before an animation for preparing views.


/// Setup the views position prior to the animation start.funcprepare(fromView from: UIView?, toView to: UIView?, fromLeft: Bool)

animation - the animation itself.


/// The animation.funcanimation(fromView from: UIView?, toView to: UIView?, fromLeft: Bool)

finalize - the necessary actions after the completion of the animation.


/// Cleanup the views position after the animation ended.funcfinalize(completed: Bool, fromView from: UIView?, toView to: UIView?, fromLeft: Bool)

We will not consider the implementation we are using, everything is quite transparent there, let's go straight to the three main classes that make the animation happen.


class CustomControllerContext: NSObject, UIViewControllerContextTransitioning


The execution context of the animation. To describe its function, refer to the protocol help UIViewControllerContextTransitioning:


A context object encapsulates information on the transition. It also contains the details of the transition.

The most interesting is a ban on the adaptation of this protocol:


Do not create objects that you adopt this protocol.

But we really need it to launch the standard animation engine, so we are still adapting it. There is almost no logic in it, it only stores state. Therefore, I will not even bring him here. It can be viewed on GitHub .


It works great on timed animations. But when it is used for interactive animations, one problem arises - it UIPercentDrivenInteractiveTransitioncauses an undocumented method in the context. The only correct solution in this situation is to adapt another protocol - UIViewControllerInteractiveTransitioningto use its own context.


class PercentDrivenInteractiveTransition: NSObject, UIViewControllerInteractiveTransitioning


Here it is - the heart of the project, allowing the existence of interactive animations in custom controller-containers. Let us analyze it in order.


The class is initialized with one type parameter UIViewControllerAnimatedTransitioning; it is a standard protocol for animation of transition between controllers. This way we will be able to use any of the already written animations along with our class.


init(with animator: UIViewControllerAnimatedTransitioning) {
    self.animator = animator
}

The public interface is fairly simple, four methods, the functionality of which should be obvious.


We only need to mark the moment of the beginning of the animation, we take the parent view of the container and set the layer speed to 0, so we can control the progress of the animation manually.


// MARK: - PublicfuncstartInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
    self.transitionContext = transitionContext
    transitionContext.containerView.superview?.layer.speed = 0
    animator.animateTransition(using: transitionContext)
}
funcupdateInteractiveTransition(percentComplete: CGFloat) {
    setPercentComplete(percentComplete: (CGFloat(fmaxf(fminf(Float(percentComplete), 1), 0))))
}
funccancelInteractiveTransition() {
    transitionContext?.cancelInteractiveTransition()
    completeTransition()
}
funcfinishInteractiveTransition() {
    transitionContext?.finishInteractiveTransition()
    completeTransition()
}

We now turn to the private block of logic of our class.


setPercentComplete sets the temporal displacement of the animation progress for the superview layer, calculating the value from the percentage of completeness and duration of the animation.


privatefuncsetPercentComplete(percentComplete: CGFloat) {
    setTimeOffset(timeOffset: TimeInterval(percentComplete) * duration)
    transitionContext?.updateInteractiveTransition(percentComplete)
}
privatefuncsetTimeOffset(timeOffset: TimeInterval) {
    transitionContext?.containerView.superview?.layer.timeOffset = timeOffset
}

completeTransitioncalled at the moment when the user stopped his gesture. Here we create an instance of the class CADisplayLinkthat will allow us to automatically finish the animation beautifully from the point when the user no longer controls its progress. We add ours displayLinkin run looporder for the system to call our selector whenever it needs to display a new frame on the device screen.


privatefunccompleteTransition() {
    displayLink = CADisplayLink(target: self, selector: #selector(tickAnimation))
    displayLink!.add(to: .main, forMode: .commonModes)
}

In our selector, we calculate and set the temporal displacement of the animation progress as we did before during the user's gesture, or we finish the animation when we reach its initial or final point.


@objcprivatefunctickAnimation() {
    var timeOffset = self.timeOffset()
    let tick = (displayLink?.duration ?? 0) * TimeInterval(completionSpeed)
    timeOffset += (transitionContext?.transitionWasCancelled ?? false) ? -tick : tick;
    if (timeOffset < 0 || timeOffset > duration) {
        transitionFinished()
    } else {
        setTimeOffset(timeOffset: timeOffset)
    }
}
privatefunctimeOffset() -> TimeInterval {
    return transitionContext?.containerView.superview?.layer.timeOffset ?? 0
}

Completing the animation, we disable ours displayLink, return the layer speed, and if the animation has not been canceled, that is, it has reached its final frame, we calculate the time from which the animation of the layer should start. You can learn more about this in the Core Animation Programming Guide, or here in this response to stackoverflow.


privatefunctransitionFinished() {
    displayLink?.invalidate()
    guardlet layer = transitionContext?.containerView.superview?.layer else {
        return
    }
    layer.speed = 1;
    let wasNotCanceled = !(transitionContext?.transitionWasCancelled ?? false)
    if (wasNotCanceled) {
        let pausedTime = layer.timeOffset
        layer.timeOffset = 0.0;
        let timeSincePause = layer.convertTime(CACurrentMediaTime(), from: nil) - pausedTime
        layer.beginTime = timeSincePause
    }
    animator.animationEnded?(wasNotCanceled)
}

class AnimatedTransitioning: NSObject, UIViewControllerAnimatedTransitioning


The last class, which we have not yet sorted out - implementation of the protocol UIViewControllerAnimatedTransitioningin which we manage the execution order of the methods of our animation protocol addTo, prepare, animation, finalize. Everything is quite prosaic here, it is worth noting only the use of UIViewPropertyAnimatoranimation to perform instead of the more typical one UIView.animate(withDuration:animations:). This is done in order to be able to further control the course of the animation, and in case of its cancellation, return it to the initial position with a call finishAnimation(at: .start), thus avoiding unnecessary blinking of the final animation frame on the screen.


Epilogue


We have created a working demo of the interface similar to the Snapchat interface. In my version, I configured the constraints so that there were fields left and right of the card, and I also left the camera to work on the background view to create the effect behind the card. This is done solely to demonstrate the capabilities of this approach, as it will affect the performance of the device and the charge of its battery, I did not check.


This article is my first attempt at writing in the genre of technical literature, I could have missed some important points, so I’m ready to gladly answer questions in the comments. Thanks to everyone who read my article, I hope you found something useful for yourself here.


The finished project can be downloaded from GitHub by reference .


Thanks again, all a good day, interesting tasks, productive coding!



Information sources


To write this program, I used the following information:


  1. The article Custom Container View Controller Transitions, by Joachim Bondo.


    The author of the article proposed a custom context context in Objective C. I used his version to write my class in Swift.


    Link


  2. The article Interactive Custom Container View Controller Transitions, by Alek Åström


    The author continued the work from the previous article and offered his approach in creating an interactive version of the animation, also in Objective C, I also used his code to write my Swift class.


    Link


  3. SwipeableTabBarController


    A project in which the author connected various interactive animations for transitions in the standard one UITabBarController. Used some ideas from the project code.


    Link



Also popular now: