在Swift上编写Snapchat UI

序言


在我的一个项目中,我需要创建一个类似于Snepchat的界面。 当带有信息的卡留在相机图像的顶部时,请以纯色平稳地替换它,并且方向也要相反。 我个人特别着迷于从摄像头窗口到侧卡的过渡,我非常高兴地叙述了解决此问题的方法。


左侧是Snepchat的示例,右侧是我们将创建的应用程序的示例。



可能想到的第一个解决方案是调整UIScrollView ,以某种方式在其上排列视图,使用分页,但是坦率地说,滚动被认为可以解决完全不同的问题,在其上拾取其他动画非常耗时,并且没有必要的灵活性设置。 因此,用它来解决这个问题是绝对不合理的。


摄像机窗口和侧边选项卡之间的滚动具有欺骗性-根本不是滚动,它是属于不同控制器的视图之间的交互式过渡。 下部的按钮是普通的选项卡,单击它们会将我们扔到控制器之间。



通过这种方式,Snatch将其自己的导航控制器版本(例如UITabBarController与自定义交互式转换一起使用。


UIKit包括两个用于导航控制器的选项,它们允许您自定义过渡UINavigationControllerUITabBarController 。 他们两个都在各自的委托中分别具有navigationController(_:interactionControllerFor:)tabBarController(_:interactionControllerFor:)方法,这使我们能够使用自己的交互式动画进行过渡。


tabBarController(_:interactionControllerFor :)


navigationController(_:interactionControllerFor :)


但是我不想受到UITabBarControllerUINavigationController的实现的限制,特别是因为我们无法控制它们的内部逻辑。 因此,我决定编写类似的控制器,现在我想告诉和展示它的来龙去脉。


问题陈述


创建自己的容器控制器,您可以使用UITabBarControllerUINavigationController的标准机制,使用交互式动画在子控制器之间进行切换,以进行过渡。 我们需要这种标准机制来使用已经编写的UIViewControllerAnimatedTransitioning类型的现成过渡动画。


项目准备


通常,我尝试将模块移到单独的框架中,为此,我创建了一个新的应用程序项目,并在其中添加了一个额外的Cocoa Touch Framework目标,然后将项目中的源分散到相应的目标。 这样,我获得了带有测试应用程序的单独框架以进行调试。


创建一个Single View App



Product Name将是我们的目标。



单击+添加目标。



选择Cocoa Touch Framework



我们将框架称为适当的名称,Xcode会自动为目标选择项目,并提供将二进制文件直接绑定到应用程序中的功能。 我们同意。



我们不需要默认的Main.storyboardViewController.swift ,我们将其删除。



另外,不要忘记从“ General选项卡上应用程序目标中的Main Interface中删除该值。



现在,我们转到AppDelegate.swift并仅保留以下内容的application方法:


 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { // Launch our master view controller let master = MasterViewController() window = UIWindow() window?.rootViewController = master window?.makeKeyAndVisible() return true } 

在这里,我们将控制器设置在主要位置,以便它出现在启动器之后。


现在创建这个MasterViewController 。 它与应用程序有关,因此在创建文件时选择正确的目标很重要。



我们将从SnapchatNavigationController继承MasterViewController ,稍后将在框架中实现。 不要忘记指定我们框架的import 。 我没有在此处提供完整的控制器代码,省略号由省略号表示... ,我将应用程序放置在GitHub上 ,您可以在其中看到所有详细信息。 在此控制器中,我们只对viewDidLoad()方法感兴趣,该方法使用相机+一个透明控制器(主窗口)+包含离场卡的控制器来初始化背景控制器。


 import MakingSnapchatNavigation class MasterViewController: SnapchatNavigationController { override func viewDidLoad() { 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) } } 

这是怎么回事 我们使用相机创建一个控制器,并使用SnapchatNavigationControllersetBackground方法将其设置为背景。 该控制器包含一个从摄像机整个视图延伸的图像。 然后,我们创建一个空的透明控制器并将其添加到数组中,它只是将相机中的图像传递给它,我们可以在其上放置控件,创建另一个透明控制器,向其中添加滚动,在滚动中添加包含内容的视图,向其中添加第二个控制器数组并使用父SnapchatNavigationController的特殊setViewControllers方法设置此数组。


不要忘记在Info.plist添加使用相机的请求


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

在此基础上,我们认为测试应用程序已准备就绪,然后转到最有趣的部分-框架的实现。


父控制器结构


首先,创建一个空的SnapchatNavigationController ,为它选择正确的目标很重要。 如果一切都正确完成,则应构建应用程序。 该项目的状态可以通过引用卸载。


 open class SnapchatNavigationController: UIViewController { override open func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. } // MARK: - Public interface /// Sets view controllers. public func setViewControllers(vcs: [UIViewController]) { } /// Sets background view. public func setBackground(vc: UIViewController) { } } 

现在添加控制器将组成的内部组件。 我没有将所有代码都放在这里,我只关注重点。


我们设置变量以存储子控制器数组。 现在,我们严格设置其所需数量-2件。 将来,将有可能扩展控制器逻辑以用于任何数量的控制器。 我们还设置了一个变量来存储当前显示的控制器。


 private let requiredChildrenAmount = 2 // MARK: - View controllers /// top child view controller private var topViewController: UIViewController? /// all children view controllers private var children: [UIViewController] = [] 

创建视图。 我们需要一个背景视图,一个需要在更改控制器时应用于背景的视图。 我们还有一个用于当前子控制器的视图容器和一个视图指示器,该指示器将告诉用户如何使用导航。


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

在下一个块中,我们设置两个变量, swipeAnimator负责动画, swipeInteractor负责交互(控制动画进度的能力),我们必须在控制器启动过程中对其进行初始化,因此我们要强制展开。


 // MARK: - Animation and transition private let swipeAnimator = AnimatedTransitioning() private var swipeInteractor: CustomSwipeInteractor! 

我们还为指标设置了转换。 我们将指示器移动容器的宽度+从边缘移动两倍+指示器本身的宽度,以使指示器位于容器的另一端。 容器的宽度在应用过程中将是已知的,因此该变量可随时进行计算。


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

加载控制器时,我们将self分配给动画(我们将在下面实现相应的协议),并根据动画初始化交互器,并控制动画的进度。 我们还任命他为代表。 代表将响应用户手势的开始,并根据控制器的状态开始动画或取消动画。 然后,我们将所有视图添加到主视图中,并调用setupViews()来设置约束。


 override open func viewDidLoad() { 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() } 

接下来,我们继续介绍在容器中安装和删除子控制器的逻辑。 这里的一切都很简单,就像Apple文档中的一样。 我们使用针对此类操作规定的方法。


addChildViewController(vc) -在当前控制器上添加一个子控制器。


contentViewContainer.addSubview(vc.view) -将控制器视图添加到视图层次结构。


vc.view.frame = contentViewContainer.bounds将视图拉伸到整个容器。 由于我们在这里使用框架而不是自动布局,因此每次控制器大小更改时我们都需要更改其大小,因此我们将省略此逻辑,并假设容器在应用程序运行时不会更改应用程序的大小。


vc.didMove(toParentViewController: self) -终止添加子控制器的操作。


swipeInteractor.wireTo我们将当前控制器绑定到用户手势。 稍后我们将分析这种方法。


 // MARK: - Private methods private func addChild(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) } private func removeChild(vc: UIViewController) { vc.willMove(toParentViewController: nil) vc.view.removeFromSuperview() vc.removeFromParentViewController() topViewController = nil } 

还有另外两种方法,我在这里不再赘述: setViewControllerssetBackground 。 在setViewControllers方法中setViewControllers我们只需在控制器的相应变量中设置子控制器的数组,然后调用addChild即可在视图中显示其中的一个。 在setBackground方法中setBackground我们仅对后台控制器执行与addChild相同的操作。


容器控制器动画逻辑


总计,我们的父控制器的基础是:


  • UIView分为两种类型
    • 货柜
    • 普通的
  • 子UIViewController的列表
  • swipeAnimator类型的AnimatedTransitioning动画控制对象
  • 一个对象,用于控制类型CustomSwipeInteractorswipeInteractor动画的交互过程
  • 委托互动动画
  • 动画协议实现

现在,我们将分析最后两点,然后继续执行AnimatedTransitioningCustomSwipeInteractor


委托互动动画


委托仅由一个panGestureDidStart(rightToLeftSwipe: Bool) -> Bool方法组成,该方法通知控制器手势的开始及其方向。 作为响应,他等待有关动画是否可以视为开始的信息。


作为代表,我们检查控制器的当前顺序,以了解是否可以按给定的方向启动动画,如果一切正常,则使用以下参数启动transition方法:参数:我们要从中移动的控制器,我们要移动的控制器,移动的方向,交互性标志(如果为false ,则会触发一个固定时间的过渡动​​画)。


 func panGestureDidStart(rightToLeftSwipe: Bool) -> Bool { guard let topViewController = topViewController, let fromIndex = children.index(of: topViewController) else { return false } let newIndex = rightToLeftSwipe ? 1 : 0 //   -    if newIndex > -1 && newIndex < children.count && newIndex != fromIndex { transition(from: children[fromIndex], to: children[newIndex], goingRight: rightToLeftSwipe, interactive: true) return true } return false } 

让我们立即检查transition方法的主体。 首先,我们为CustomControllerContext动画创建动画上下文。 稍后我们还将分析此类;该类实现了UIViewControllerContextTransitioning协议。 对于UINavigationControllerUITabBarController此协议的实现实例由系统自动创建,并且其逻辑对我们隐藏,我们需要创建自己的逻辑。


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

然后我们简单地称为固定或交互式动画。 将来,可以在控制器之间的导航按钮选项卡上挂一个固定的按钮,在此示例中,我们将不这样做。


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

动画协议


TransitionAnimation动画协议包含4种方法:


addTo是一种用于在容器中创建正确的子视图结构的方法,根据动画的想法,前一个视图与新视图重叠。


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

prepare是在动画之前准备视图的方法。


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

animation动画本身。


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

finalize -动画完成后的必要操作。


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

我们将不考虑所使用的实现,那里的一切都非常透明,我们将直接进入三个主要类,这要归功于动画的发生。


class CustomControllerContext: NSObject, UIViewControllerContextTransitioning


动画的上下文。 为了描述其功能,我们参考UIViewControllerContextTransitioning协议的帮助:


上下文对象封装有关转换中涉及的视图和视图控制器的信息。 它还包含有关如何执行过渡的详细信息。

最有趣的是禁止修改此协议:


不要在自己的类中采用此协议,也不要直接创建采用此协议的对象。

但是我们确实需要它来运行标准动画引擎,因此无论如何我们都会对其进行调整。 它几乎没有逻辑;它仅存储状态。 因此,我什至不会把它带到这里。 您可以在GitHub上观看它。


它适用于固定时间的动画。 但是,当将其用于交互式动画时,会出现一个问题UIPercentDrivenInteractiveTransition会在上下文中调用未记录的方法。 在这种情况下,唯一正确的解决方案是改编另一种协议UIViewControllerInteractiveTransitioning以使用您自己的上下文。


class PercentDrivenInteractiveTransition: NSObject, UIViewControllerInteractiveTransitioning


这是项目的核心-允许交互式动画存在于自定义容器控制器中。 让我们按顺序进行。


该类使用UIViewControllerAnimatedTransitioning类型的一个参数初始化,这是用于动画化控制器之间过渡的标准协议。 这样,我们可以使用已经与类一起编写的任何动画。


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

公共接口非常简单,有四种方法,其功能应该很明显。


只需注意动画开始的那一刻,我们就可以获取容器的父视图并将图层速度设置为0,这样我们就可以手动控制动画的进度。


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

现在我们来看我们班级的私有逻辑模块。


setPercentComplete设置setPercentComplete视图层的动画进度的时间偏移量,并根据动画的完成百分比和持续时间来计算该值。


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

当用户停止手势时会调用completeTransition 。 在这里,我们创建了CADisplayLink类的实例,该实例将使我们能够从用户不再控制其进度的那一刻起自动漂亮地自动完成动画。 我们将displayLink添加到run loop以便系统在需要在设备屏幕上显示新帧时调用选择器。


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

在选择器中,我们计算并设置动画进度的临时位移,就像我们在用户手势操作之前所做的那样,或者在动画到达其起点或终点时完成动画。


 @objc private func tickAnimation() { 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) } } private func timeOffset() -> TimeInterval { return transitionContext?.containerView.superview?.layer.timeOffset ?? 0 } 

完成动画后,我们关闭displayLink ,返回图层的速度,如果尚未取消动画(即动画已到达其最后一帧),我们将计算图层动画应开始的时间。 您可以在《核心动画编程指南》中或在stackoverflow的此答案中了解有关此内容的更多信息。


 private func transitionFinished() { displayLink?.invalidate() guard let 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


我们尚未检查的最后一个类是UIViewControllerAnimatedTransitioning协议的实现,其中我们控制动画addToprepareanimationfinalize的协议方法的执行顺序。 这里的一切都是平淡无奇的,值得注意的是,仅使用UIViewPropertyAnimator来执行动画,而不是使用更典型的UIView.animate(withDuration:animations:) 。 这样做是为了可以进一步控制动画的进度,如果取消了动画,则可以通过调用finishAnimation(at: .start)将其返回到其finishAnimation(at: .start)位置,从而避免了动画的最后一帧在屏幕上不必要的闪烁。


结语


我们创建了一个类似于Snapchat的界面的工作演示。 在我的版本中,我配置了常量,以便在卡的左右两边都有字段。此外,我将相机放在背景视图上以在卡的后面创建效果。 这样做只是为了演示这种方法的功能,它如何影响设备的性能,而我没有检查其电池电量。


本文是我第一次尝试撰写技术文献类型的文章,我可能会漏掉一些要点,因此很高兴在评论中回答问题。感谢所有阅读我的文章的人,希望您在这里找到了对自己有用的东西。


可以从GitHub的链接下载完成的项目


再次感谢,大家度过了愉快的一天,有趣的任务,高效的编码!



信息来源


为了编写该程序,我使用了以下信息:


  1. Joachim Bondo撰写的“定制容器视图控制器转换”一文。


    本文的作者提出了目标C中自定义上下文的一种变体。我用它的变体在Swift中编写类。


    友情链接


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


    , Objective C, Swift.



  3. SwipeableTabBarController


    , UITabBarController . .



Source: https://habr.com/ru/post/zh-CN415795/


All Articles