Skip to content

Commit

Permalink
Fix #160 - CoachMark misplaced on iPad
Browse files Browse the repository at this point in the history
  • Loading branch information
ephread committed Jun 6, 2018
1 parent 6c4dc0d commit bd07804
Show file tree
Hide file tree
Showing 4 changed files with 187 additions and 44 deletions.
4 changes: 4 additions & 0 deletions Sources/Instructions/Core/Public/CoachMark.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
71 changes: 59 additions & 12 deletions Sources/Instructions/Helpers/Internal/CoachMarkLayoutHelper.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// CoachMarkLayoutHelper.swift
//
// Copyright (c) 2016 Frédéric Maquin <fred@ephread.com>
// Copyright (c) 2016, 2018 Frédéric Maquin <fred@ephread.com>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
Expand All @@ -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 []
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -133,7 +180,7 @@ class CoachMarkLayoutHelper {
return pointOfInterest.x - coachMark.horizontalMargin
} else {
return parentView.bounds.size.width - pointOfInterest.x -
coachMark.horizontalMargin
coachMark.horizontalMargin
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
125 changes: 93 additions & 32 deletions Sources/Instructions/Managers/Internal/CoachMarkDisplayManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ class CoachMarkDisplayManager {
UIView.animate(withDuration: coachMark.animationDuration, animations: { () -> Void in
coachMarkView.alpha = 1.0
}, completion: { _ in
print(coachMarkView)
completion?()
})
} else {
Expand Down Expand Up @@ -148,59 +149,119 @@ 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,
coachMark: coachMark,
parentView: parentView)

parentView.addConstraints(constraints)
overlayView.cutoutPath = cutoutPath
} else {
overlayView.cutoutPath = nil
}
}
}

0 comments on commit bd07804

Please sign in to comment.