ephread/Instructions

View on GitHub
Sources/Instructions/Managers/Internal/CoachMarkDisplayManager.swift

Summary

Maintainability
C
1 day
Test Coverage
D
68%
// Copyright (c) 2015-present Frédéric Maquin <fred@ephread.com> and contributors.
// Licensed under the terms of the MIT License.

import UIKit

/// This class deals with the layout of coach marks.
class CoachMarkDisplayManager {
    // MARK: - Public properties
    weak var dataSource: CoachMarksControllerProxyDataSource!
    weak var animationDelegate: CoachMarksControllerAnimationProxyDelegate?
    weak var overlayManager: OverlayManager?

    // MARK: - Private properties
    /// The coach mark view (the one displayed)
    private var coachMarkView: CoachMarkView!

    private let coachMarkLayoutHelper: CoachMarkLayoutHelper

    // MARK: - Initialization
    /// Allocate and initialize the manager.
    ///
    /// - Parameter coachMarkLayoutHelper: auto-layout constraint generator
    init(coachMarkLayoutHelper: CoachMarkLayoutHelper) {
        self.coachMarkLayoutHelper = coachMarkLayoutHelper
    }

    func createCoachMarkView(from coachMark: CoachMark, at index: Int) -> CoachMarkView {
        // Asks the data source for the appropriate tuple of views.
        let coachMarkComponentViews =
            dataSource.coachMarkViews(at: index, madeFrom: coachMark)

        // Creates the CoachMarkView, from the supplied component views.
        // CoachMarkView() is not a failable initializer. We'll force unwrap
        // currentCoachMarkView everywhere.
        if coachMark.isDisplayedOverCutoutPath {
            // No arrow should be shown when displayed above the cutoutPath.
            return CoachMarkView(bodyView: coachMarkComponentViews.bodyView,
                                 coachMarkInnerLayoutHelper: CoachMarkInnerLayoutHelper())
        } else {
            return CoachMarkView(bodyView: coachMarkComponentViews.bodyView,
                                 arrowView: coachMarkComponentViews.arrowView,
                                 arrowOrientation: coachMark.arrowOrientation,
                                 arrowOffset: coachMark.gapBetweenBodyAndArrow,
                                 coachMarkInnerLayoutHelper: CoachMarkInnerLayoutHelper())
        }
    }

    // TODO: ❗️ Refactor this method into smaller components
    /// Hide the given CoachMark View
    ///
    /// - Parameters:
    ///   - coachMarkView: the coach mark view to show.
    ///   - coachMark: the coach mark metadata
    ///   - index: the current index at which the coach mark is displayed
    ///   - animated: `true` to animate the coach mark appearance,`false` otherwise.
    ///   - beforeTransition: `true` if the coach mark is hidden because a transition
    ///                        is about to happen.
    ///   - completion: a handler to call after the coach mark was successfully hidden.
    func hide(coachMarkView: UIView, from coachMark: CoachMark, at index: Int,
              animated: Bool, beforeTransition: Bool, completion: (() -> Void)? = nil) {
        guard let overlay = overlayManager else { return }

        guard animated else {
            if !beforeTransition {
                overlay.showCutoutPath(false, withDuration: 0)
            }

            coachMarkView.alpha = 0.0
            coachMarkView.removeFromSuperview()
            completion?()

            return
        }

        let transitionManager = CoachMarkTransitionManager(coachMark: coachMark)

        animationDelegate?.fetchDisappearanceTransition(OfCoachMark: coachMarkView, at: index,
                                                        using: transitionManager)

        if !beforeTransition {
            overlay.showCutoutPath(false, withDuration: transitionManager.parameters.duration)
        }

        guard let animations = transitionManager.animations else {
            UIView.animate(withDuration: transitionManager.parameters.duration,
                           animations: { coachMarkView.alpha = 0.0 },
                           completion: { _ in
                coachMarkView.removeFromSuperview()
                completion?()
            })

            return
        }

        let completionBlock: (Bool) -> Void = { success in
            coachMarkView.removeFromSuperview()
            completion?()
            transitionManager.completion?(success)
        }

        let context = transitionManager.createContext()
        let animationBlock = { animations(context) }

        transitionManager.initialState?()

        if transitionManager.animationType == .regular {
            UIView.animate(withDuration: transitionManager.parameters.duration,
                           delay: transitionManager.parameters.delay,
                           options: transitionManager.parameters.options,
                           animations: animationBlock, completion: completionBlock)
        } else {
            UIView.animateKeyframes(withDuration: transitionManager.parameters.duration,
                                    delay: transitionManager.parameters.delay,
                                    options: transitionManager.parameters.keyframeOptions,
                                    animations: animationBlock, completion: completionBlock)
        }
    }

    // TODO: ❗️ Refactor this method into smaller components
    /// Display the given CoachMark View
    ///
    /// - Parameters:
    ///   - coachMarkView: the coach mark view to show.
    ///   - coachMark: the coach mark metadata
    ///   - index: the current index at which the coach mark is displayed
    ///   - animated: `true` to animate the coach mark appearance,`false` otherwise.
    ///   - beforeTransition: `true` if the coach mark is hidden because a transition
    ///                        is about to happen.
    ///   - completion: a handler to call after the coach mark was successfully displayed.
    func showNew(coachMarkView: CoachMarkView, from coachMark: CoachMark,
                 at index: Int, animated: Bool = true, completion: (() -> Void)? = nil) {
        guard let overlay = overlayManager else { return }

        prepare(coachMarkView: coachMarkView, forDisplayIn: overlay.overlayView.superview!,
                usingCoachMark: coachMark, andOverlayView: overlay.overlayView)

        overlay.enableTap = coachMark.isOverlayInteractionEnabled
        overlay.isUserInteractionEnabledInsideCutoutPath =
            coachMark.isUserInteractionEnabledInsideCutoutPath

        guard animated else {
            overlay.showCutoutPath(true, withDuration: 0)
            coachMarkView.alpha = 1.0
            completion?()
            return
        }

        let transitionManager = CoachMarkTransitionManager(coachMark: coachMark)

        animationDelegate?.fetchAppearanceTransition(OfCoachMark: coachMarkView, at: index,
                                                     using: transitionManager)

        overlay.showCutoutPath(true, withDuration: transitionManager.parameters.duration)

        guard let animations = transitionManager.animations else {
            // The view shall be invisible, 'cause we'll animate its entry.
            coachMarkView.alpha = 0.0

            UIView.animate(withDuration: transitionManager.parameters.duration,
                           animations: { coachMarkView.alpha = 1.0 },
                           completion: { [weak self] _ in
                completion?()
                self?.applyIdleAnimation(to: coachMarkView, from: coachMark, at: index)
            })

            return
        }

        let completionBlock: (Bool) -> Void = { [weak self] success in
            completion?()
            transitionManager.completion?(success)
            self?.applyIdleAnimation(to: coachMarkView, from: coachMark, at: index)
        }

        let context = transitionManager.createContext()
        let animationBlock = { animations(context) }

        transitionManager.initialState?()

        if transitionManager.animationType == .regular {
            UIView.animate(withDuration: transitionManager.parameters.duration,
                           delay: transitionManager.parameters.delay,
                           options: transitionManager.parameters.options,
                           animations: animationBlock, completion: completionBlock)
        } else {
            UIView.animateKeyframes(withDuration: transitionManager.parameters.duration,
                                    delay: transitionManager.parameters.delay,
                                    options: transitionManager.parameters.keyframeOptions,
                                    animations: animationBlock, completion: completionBlock)
        }
    }

    // MARK: - Private methods
    /// Add the current coach mark to the view, making sure it is
    /// properly positioned.
    ///
    /// - Parameters:
    ///   - coachMarkView: the coach mark to display
    ///   - parentView: the view in which display coach marks
    ///   - coachMark: the coachmark data
    ///   - overlayView: the overlayView (covering everything and showing cutouts)
    private func prepare(coachMarkView: CoachMarkView, forDisplayIn parentView: UIView,
                         usingCoachMark coachMark: CoachMark,
                         andOverlayView overlayView: OverlayView) {
        // Add the view and compute its associated constraints.
        parentView.addSubview(coachMarkView)

        coachMarkView.widthAnchor
                     .constraint(lessThanOrEqualToConstant: coachMark.maxWidth).isActive = true

        // No cutoutPath, no arrow.
        if let cutoutPath = coachMark.cutoutPath {

            generateAndEnableVerticalConstraints(of: coachMarkView, forDisplayIn: parentView,
                                                 usingCoachMark: coachMark, cutoutPath: cutoutPath,
                                                 andOverlayView: overlayView)

            generateAndEnableHorizontalConstraints(of: coachMarkView, forDisplayIn: parentView,
                                                  usingCoachMark: coachMark,
                                                  andOverlayView: overlayView)

            overlayView.cutoutPath = cutoutPath
        } else {
            overlayView.cutoutPath = nil
        }
    }

    /// Generate the vertical constraints needed to lay out `coachMarkView` above or below the
    /// cutout path.
    ///
    /// - Parameters:
    ///   - coachMarkView: the coach mark to display
    ///   - parentView: the view in which display coach marks
    ///   - coachMark: the coachmark data
    ///   - cutoutPath: the cutout path
    ///   - overlayView: the overlayView (covering everything and showing cutouts)
    private func generateAndEnableVerticalConstraints(of coachMarkView: CoachMarkView,
                                                      forDisplayIn parentView: UIView,
                                                      usingCoachMark coachMark: CoachMark,
                                                      cutoutPath: UIBezierPath,
                                                      andOverlayView overlayView: OverlayView) {
        let offset = coachMark.gapBetweenCoachMarkAndCutoutPath

        // Depending where the cutoutPath sits, the coach mark will either
        // stand above or below it. Alternatively, it can also be displayed
        // over the cutoutPath.
        if coachMark.isDisplayedOverCutoutPath {
            let constant = cutoutPath.bounds.midY - parentView.frame.size.height / 2

            coachMarkView.centerYAnchor.constraint(equalTo: parentView.centerYAnchor,
                                                   constant: constant).isActive = true
        } else if coachMark.arrowOrientation! == .bottom {
            let constant = -(parentView.frame.size.height -
                cutoutPath.bounds.origin.y + offset)

            coachMarkView.bottomAnchor.constraint(equalTo: parentView.bottomAnchor,
                                                  constant: constant).isActive = true
        } else {
            let constant = (cutoutPath.bounds.origin.y +
                cutoutPath.bounds.size.height) + offset

            coachMarkView.topAnchor.constraint(equalTo: parentView.topAnchor,
                                               constant: constant).isActive = true
        }
    }

    /// Generate horizontal constraints needed to lay out `coachMarkView` at the
    /// right place. This method uses a two-pass mechanism, whereby the `coachMarkView` is
    /// at first laid out around the center of the point of interest. If it turns out
    /// that the `coachMarkView` is partially out of the bounds of its parent (margins included),
    /// the view is laid out again using the 3-segment mechanism.
    ///
    /// - Parameters:
    ///   - coachMarkView: the coach mark to display
    ///   - parentView: the view in which display coach marks
    ///   - coachMark: the coachmark data
    ///   - overlayView: the overlayView (covering everything and showing cutouts)
    private func generateAndEnableHorizontalConstraints(of coachMarkView: CoachMarkView,
                                                        forDisplayIn parentView: UIView,
                                                        usingCoachMark coachMark: CoachMark,
                                                        andOverlayView overlayView: OverlayView) {
        // Generating the constraints for the first pass. This constraints center
        // the view around the point of interest.
        let constraints = coachMarkLayoutHelper.constraints(for: coachMarkView,
                                                            coachMark: coachMark,
                                                            parentView: parentView)

        // Laying out the view
        parentView.addConstraints(constraints)
        parentView.setNeedsLayout()
        parentView.layoutIfNeeded()

        // If the view turns out to be partially outside of the screen, constraints are
        // computed again and the view is laid out for the second time.
        let insets = UIEdgeInsets(top: 0, left: coachMark.horizontalMargin,
                                  bottom: 0, right: coachMark.horizontalMargin)

        if coachMarkView.isOutOfSuperview(consideringInsets: insets) {
            // Removing previous constraints.
            for constraint in constraints {
                parentView.removeConstraint(constraint)
            }

            let constraints = coachMarkLayoutHelper.constraints(for: coachMarkView,
                                                                coachMark: coachMark,
                                                                parentView: parentView,
                                                                passNumber: 1)

            parentView.addConstraints(constraints)
        }
    }

    /// Fetch and perform user-defined idle animation on given coach mark view.
    ///
    /// - Parameters:
    ///   - coachMarkView: the view to animate.
    ///   - coachMark: the related coach mark metadata.
    ///   - index: the index of the coach mark.
    private func applyIdleAnimation(to coachMarkView: UIView, from coachMark: CoachMark,
                                    at index: Int) {
        let transitionManager = CoachMarkAnimationManager(coachMark: coachMark)

        animationDelegate?.fetchIdleAnimationOfCoachMark(OfCoachMark: coachMarkView, at: index,
                                                         using: transitionManager)

        if let animations = transitionManager.animations {
            let context = transitionManager.createContext()
            let animationBlock = { animations(context) }

            if transitionManager.animationType == .regular {
                UIView.animate(withDuration: transitionManager.parameters.duration,
                               delay: transitionManager.parameters.delay,
                               options: transitionManager.parameters.options,
                               animations: animationBlock, completion: nil)
            } else {
                UIView.animateKeyframes(withDuration: transitionManager.parameters.duration,
                                        delay: transitionManager.parameters.delay,
                                        options: transitionManager.parameters.keyframeOptions,
                                        animations: animationBlock, completion: nil)
            }
        }
    }
}