diff --git a/Sources/Instructions/Core/Public/CoachMark.swift b/Sources/Instructions/Core/Public/CoachMark.swift index ae518c81..b1c03a58 100644 --- a/Sources/Instructions/Core/Public/CoachMark.swift +++ b/Sources/Instructions/Core/Public/CoachMark.swift @@ -120,6 +120,10 @@ public struct CoachMark { self.pointOfInterest = CGPoint(x: xVal, y: yVal) } + + internal func ceiledMaxWidth(in frame: CGRect) -> CGFloat { + return min(maxWidth, frame.width - 2 * horizontalMargin) + } } extension CoachMark: Equatable {} diff --git a/Sources/Instructions/Helpers/Internal/CoachMarkLayoutHelper.swift b/Sources/Instructions/Helpers/Internal/CoachMarkLayoutHelper.swift index e0873c1c..989a483b 100644 --- a/Sources/Instructions/Helpers/Internal/CoachMarkLayoutHelper.swift +++ b/Sources/Instructions/Helpers/Internal/CoachMarkLayoutHelper.swift @@ -1,6 +1,6 @@ // CoachMarkLayoutHelper.swift // -// Copyright (c) 2016 Frédéric Maquin +// Copyright (c) 2016, 2018 Frédéric Maquin // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -22,13 +22,17 @@ import UIKit -// swiftlint:disable line_length class CoachMarkLayoutHelper { var layoutDirection: UIUserInterfaceLayoutDirection = .leftToRight // TODO: Improve the layout system. Make it smarter. - func constraints(for coachMarkView: CoachMarkView, coachMark: CoachMark, parentView: UIView, - layoutDirection: UIUserInterfaceLayoutDirection? = nil) -> [NSLayoutConstraint] { + func constraints( + for coachMarkView: CoachMarkView, + coachMark: CoachMark, + parentView: UIView, + layoutDirection: UIUserInterfaceLayoutDirection? = nil, + firstPass: Bool = false + ) -> [NSLayoutConstraint] { if coachMarkView.superview != parentView { print("coachMarkView was not added to parentView, returned constraints will be empty") return [] @@ -37,15 +41,25 @@ class CoachMarkLayoutHelper { if layoutDirection == nil { if #available(iOS 9, *) { self.layoutDirection = UIView.userInterfaceLayoutDirection( - for: parentView.semanticContentAttribute) + for: parentView.semanticContentAttribute + ) } } else { self.layoutDirection = layoutDirection! } - let computedProperties = computeProperties(for: coachMark, inParentView: parentView) - let offset = arrowOffset(for: coachMark, withProperties: computedProperties, + let computedProperties: CoachMarkComputedProperties + let offset: CGFloat + + if firstPass { + computedProperties = CoachMarkComputedProperties(layoutDirection: self.layoutDirection, + segmentIndex: 2) + offset = 0 + } else { + computedProperties = computeProperties(for: coachMark, inParentView: parentView) + offset = arrowOffset(for: coachMark, withProperties: computedProperties, inParentView: parentView) + } switch computedProperties.segmentIndex { case 1: @@ -68,19 +82,45 @@ class CoachMarkLayoutHelper { withCoachMark coachMark: CoachMark, inParentView parentView: UIView ) -> [NSLayoutConstraint] { - return NSLayoutConstraint.constraints(withVisualFormat: "H:|-(==\(coachMark.horizontalMargin))-[currentCoachMarkView(<=\(coachMark.maxWidth))]-(>=\(coachMark.horizontalMargin))-|", options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: ["currentCoachMarkView": coachMarkView]) + let visualFormat = "H:|-(==\(coachMark.horizontalMargin))-" + + "[currentCoachMarkView(<=\(coachMark.maxWidth))]-" + + "(>=\(coachMark.horizontalMargin))-|" + + return NSLayoutConstraint.constraints(withVisualFormat: visualFormat, + options: NSLayoutFormatOptions(rawValue: 0), + metrics: nil, + views: ["currentCoachMarkView": coachMarkView]) } private func middleConstraints(for coachMarkView: CoachMarkView, withCoachMark coachMark: CoachMark, inParentView parentView: UIView ) -> [NSLayoutConstraint] { - var constraints = NSLayoutConstraint.constraints(withVisualFormat: "H:|-(>=\(coachMark.horizontalMargin))-[currentCoachMarkView(<=\(coachMark.maxWidth)@1000)]-(>=\(coachMark.horizontalMargin))-|", options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: ["currentCoachMarkView": coachMarkView]) + let maxWidth = min(coachMark.maxWidth, (parentView.bounds.width - 2 * + coachMark.horizontalMargin)) + + let visualFormat = "H:[currentCoachMarkView(<=\(maxWidth)@1000)]" + + var constraints = + NSLayoutConstraint.constraints(withVisualFormat: visualFormat, + options: NSLayoutFormatOptions(rawValue: 0), + metrics: nil, + views: ["currentCoachMarkView": coachMarkView]) + + var offset: CGFloat = 0 + + if let pointOfInterest = coachMark.pointOfInterest { + if layoutDirection == .leftToRight { + offset = parentView.center.x - pointOfInterest.x + } else { + offset = pointOfInterest.x - parentView.center.x + } + } constraints.append(NSLayoutConstraint( item: coachMarkView, attribute: .centerX, relatedBy: .equal, toItem: parentView, attribute: .centerX, - multiplier: 1, constant: 0 + multiplier: 1, constant: -offset )) return constraints @@ -90,7 +130,14 @@ class CoachMarkLayoutHelper { withCoachMark coachMark: CoachMark, inParentView parentView: UIView ) -> [NSLayoutConstraint] { - return NSLayoutConstraint.constraints(withVisualFormat: "H:|-(>=\(coachMark.horizontalMargin))-[currentCoachMarkView(<=\(coachMark.maxWidth))]-(==\(coachMark.horizontalMargin))-|", options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: ["currentCoachMarkView": coachMarkView]) + let visualFormat = "H:|-(>=\(coachMark.horizontalMargin))-" + + "[currentCoachMarkView(<=\(coachMark.maxWidth))]-" + + "(==\(coachMark.horizontalMargin))-|" + + return NSLayoutConstraint.constraints(withVisualFormat: visualFormat, + options: NSLayoutFormatOptions(rawValue: 0), + metrics: nil, + views: ["currentCoachMarkView": coachMarkView]) } /// Returns the arrow offset, based on the layout and the @@ -133,7 +180,7 @@ class CoachMarkLayoutHelper { return pointOfInterest.x - coachMark.horizontalMargin } else { return parentView.bounds.size.width - pointOfInterest.x - - coachMark.horizontalMargin + coachMark.horizontalMargin } } diff --git a/Sources/Instructions/Helpers/Internal/Extensions/UIView+Layout.swift b/Sources/Instructions/Helpers/Internal/Extensions/UIView+Layout.swift index f376e1a2..9c5ec652 100644 --- a/Sources/Instructions/Helpers/Internal/Extensions/UIView+Layout.swift +++ b/Sources/Instructions/Helpers/Internal/Extensions/UIView+Layout.swift @@ -23,6 +23,37 @@ import UIKit internal extension UIView { + + var isOutOfSuperview: Bool { + guard let superview = self.superview else { + return true + } + + let intersectedFrame = superview.bounds.intersection(self.frame) + + let isInBounds = fabs(intersectedFrame.origin.x - self.frame.origin.x) < 1 && + fabs(intersectedFrame.origin.y - self.frame.origin.y) < 1 && + fabs(intersectedFrame.size.width - self.frame.size.width) < 1 && + fabs(intersectedFrame.size.height - self.frame.size.height) < 1 + + return !isInBounds + } + + func isOutOfSuperview(consideringInsets insets: UIEdgeInsets) -> Bool { + guard let superview = self.superview else { + return true + } + + let intersectedFrame = superview.bounds.intersection(self.frame) + + let isInBounds = fabs(intersectedFrame.origin.x - (self.frame.origin.x + insets.left)) < 1 && + fabs(intersectedFrame.origin.y - (self.frame.origin.y + insets.top)) < 1 && + fabs(intersectedFrame.size.width - self.frame.size.width - (insets.left + insets.right)) < 1 && + fabs(intersectedFrame.size.height - self.frame.size.height - (insets.top + insets.bottom)) < 1 + + return !isInBounds + } + func fillSuperview() { fillSuperviewVertically() fillSuperviewHorizontally() diff --git a/Sources/Instructions/Managers/Internal/CoachMarkDisplayManager.swift b/Sources/Instructions/Managers/Internal/CoachMarkDisplayManager.swift index d541be3a..01c1aa19 100644 --- a/Sources/Instructions/Managers/Internal/CoachMarkDisplayManager.swift +++ b/Sources/Instructions/Managers/Internal/CoachMarkDisplayManager.swift @@ -116,6 +116,7 @@ class CoachMarkDisplayManager { UIView.animate(withDuration: coachMark.animationDuration, animations: { () -> Void in coachMarkView.alpha = 1.0 }, completion: { _ in + print(coachMarkView) completion?() }) } else { @@ -148,49 +149,112 @@ class CoachMarkDisplayManager { /// Add the current coach mark to the view, making sure it is /// properly positioned. /// - /// - Parameter coachMarkView: the coach mark to display - /// - Parameter parentView: the view in which display coach marks - /// - Parameter coachMark: the coachmark data - /// - Parameter overlayView: the overlayView (covering everything and showing cutouts) + /// - 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) fileprivate func prepare(coachMarkView: CoachMarkView, forDisplayIn parentView: UIView, usingCoachMark coachMark: CoachMark, andOverlayView overlayView: OverlayView) { // Add the view and compute its associated constraints. parentView.addSubview(coachMarkView) - parentView.addConstraints( - NSLayoutConstraint.constraints( - withVisualFormat: "H:[currentCoachMarkView(<=\(coachMark.maxWidth))]", - options: NSLayoutFormatOptions(rawValue: 0), - metrics: nil, - views: ["currentCoachMarkView": coachMarkView] - ) - ) + coachMarkView.widthAnchor + .constraint(lessThanOrEqualToConstant: coachMark.maxWidth).isActive = true // No cutoutPath, no arrow. if let cutoutPath = coachMark.cutoutPath { - let offset = coachMark.gapBetweenCoachMarkAndCutoutPath - // Depending where the cutoutPath sits, the coach mark will either - // stand above or below it. - if coachMark.arrowOrientation! == .bottom { - let constant = -(parentView.frame.size.height - - cutoutPath.bounds.origin.y + offset) + 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) + 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. + if coachMark.arrowOrientation! == .bottom { + let constant = -(parentView.frame.size.height - + cutoutPath.bounds.origin.y + offset) - let coachMarkViewConstraint = - coachMarkView.bottomAnchor.constraint(equalTo: parentView.bottomAnchor, - constant: constant) + let coachMarkViewConstraint = + coachMarkView.bottomAnchor.constraint(equalTo: parentView.bottomAnchor, + constant: constant) - parentView.addConstraint(coachMarkViewConstraint) - } else { - let constant = (cutoutPath.bounds.origin.y + - cutoutPath.bounds.size.height) + offset + parentView.addConstraint(coachMarkViewConstraint) + } else { + let constant = (cutoutPath.bounds.origin.y + + cutoutPath.bounds.size.height) + offset + + let coachMarkViewConstraint = + coachMarkView.topAnchor.constraint(equalTo: parentView.topAnchor, + constant: constant) + + parentView.addConstraint(coachMarkViewConstraint) + } + } - let coachMarkViewConstraint = - coachMarkView.topAnchor.constraint(equalTo: parentView.topAnchor, - constant: constant) + /// 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) + 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, + firstPass: true) - parentView.addConstraint(coachMarkViewConstraint) + // 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, @@ -198,9 +262,6 @@ class CoachMarkDisplayManager { parentView: parentView) parentView.addConstraints(constraints) - overlayView.cutoutPath = cutoutPath - } else { - overlayView.cutoutPath = nil } } }