Last active
March 21, 2024 09:53
-
-
Save cedricbahirwe/3e39c10bcc6035b4648ff03aac008002 to your computer and use it in GitHub Desktop.
A Tooltip Prototype View
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
import SwiftUI | |
protocol DriosToolTipViewDelegate : AnyObject { | |
func didTapToolTip(_ tipView: DriosToolTipView) | |
func didDismissToolTip(_ tipView : DriosToolTipView) | |
} | |
// MARK: - DriosToolTipView class implementation | |
final class DriosToolTipView: UIView { | |
// MARK:- Nested types - | |
enum ArrowPosition { | |
case any | |
case top | |
case bottom | |
case right | |
case left | |
static let allValues = [top, bottom, right, left] | |
} | |
struct Preferences { | |
struct Drawing { | |
var cornerRadius = CGFloat(5) | |
var arrowHeight = CGFloat(5) | |
var arrowWidth = CGFloat(10) | |
var foregroundColor = UIColor.white | |
var backgroundColor = UIColor.green | |
var arrowPosition = ArrowPosition.bottom | |
var textAlignment = NSTextAlignment.center | |
var borderWidth = CGFloat(0) | |
var borderColor = UIColor.clear | |
var font = UIFont.systemFont(ofSize: 15) | |
var shadowColor = UIColor.clear | |
var shadowOffset = CGSize(width: 0.0, height: 0.0) | |
var shadowRadius = CGFloat(0) | |
var shadowOpacity = CGFloat(0) | |
} | |
struct Positioning { | |
var bubbleInsets = UIEdgeInsets(top: 1.0, left: 1.0, bottom: 1.0, right: 1.0) | |
var contentInsets = UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0) | |
var maxWidth = CGFloat(200) | |
} | |
struct Animating { | |
var dismissTransform = CGAffineTransform(scaleX: 0.1, y: 0.1) | |
var showInitialTransform = CGAffineTransform(scaleX: 0, y: 0) | |
var showFinalTransform = CGAffineTransform.identity | |
var springDamping = CGFloat(0.7) | |
var springVelocity = CGFloat(0.7) | |
var showInitialAlpha = CGFloat(0) | |
var dismissFinalAlpha = CGFloat(0) | |
var showDuration = 0.7 | |
var dismissDuration = 0.7 | |
var dismissOnTap = false | |
} | |
var drawing = Drawing() | |
var positioning = Positioning() | |
var animating = Animating() | |
var hasBorder : Bool { | |
return drawing.borderWidth > 0 && drawing.borderColor != UIColor.clear | |
} | |
var hasShadow : Bool { | |
return drawing.shadowOpacity > 0 && drawing.shadowColor != UIColor.clear | |
} | |
init() {} | |
} | |
enum Content: CustomStringConvertible { | |
case text(String) | |
case attributedText(NSAttributedString) | |
case view(UIView) | |
var description: String { | |
switch self { | |
case .text(let text): | |
return "text : '\(text)'" | |
case .attributedText(let text): | |
return "attributed text : '\(text)'" | |
case .view(let contentView): | |
return "view : \(contentView)" | |
} | |
} | |
} | |
// MARK: - Variables | |
override var backgroundColor: UIColor? { | |
didSet { | |
guard let color = backgroundColor | |
, color != UIColor.clear else {return} | |
preferences.drawing.backgroundColor = color | |
backgroundColor = UIColor.clear | |
} | |
} | |
override var description: String { | |
let type = "'\(String(reflecting: Swift.type(of: self)))'".components(separatedBy: ".").last! | |
return "<< \(type) with \(content) >>" | |
} | |
private weak var presentingView: UIView? | |
private weak var delegate: DriosToolTipViewDelegate? | |
private var arrowTip = CGPoint.zero | |
private(set) var preferences: Preferences | |
private let content: Content | |
// MARK: - Lazy variables | |
private lazy var contentSize: CGSize = { | |
[unowned self] in | |
switch content { | |
case .text(let text): | |
var attributes = [NSAttributedString.Key.font : self.preferences.drawing.font] | |
var textSize = text.boundingRect(with: CGSize(width: self.preferences.positioning.maxWidth, height: CGFloat.greatestFiniteMagnitude), options: NSStringDrawingOptions.usesLineFragmentOrigin, attributes: attributes, context: nil).size | |
textSize.width = ceil(textSize.width) | |
textSize.height = ceil(textSize.height) | |
if textSize.width < self.preferences.drawing.arrowWidth { | |
textSize.width = self.preferences.drawing.arrowWidth | |
} | |
return textSize | |
case .attributedText(let text): | |
var textSize = text.boundingRect(with: CGSize(width: self.preferences.positioning.maxWidth, height: CGFloat.greatestFiniteMagnitude), options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil).size | |
textSize.width = ceil(textSize.width) | |
textSize.height = ceil(textSize.height) | |
if textSize.width < self.preferences.drawing.arrowWidth { | |
textSize.width = self.preferences.drawing.arrowWidth | |
} | |
return textSize | |
case .view(let contentView): | |
return contentView.frame.size | |
} | |
}() | |
private lazy var tipViewSize: CGSize = { | |
[unowned self] in | |
var tipViewSize = | |
CGSize( | |
width: self.contentSize.width + self.preferences.positioning.contentInsets.left + self.preferences.positioning.contentInsets.right + self.preferences.positioning.bubbleInsets.left + self.preferences.positioning.bubbleInsets.right, | |
height: self.contentSize.height + self.preferences.positioning.contentInsets.top + self.preferences.positioning.contentInsets.bottom + self.preferences.positioning.bubbleInsets.top + self.preferences.positioning.bubbleInsets.bottom + self.preferences.drawing.arrowHeight) | |
return tipViewSize | |
}() | |
// MARK: - Static variables - | |
static var globalPreferences = Preferences() | |
// MARK:- Initializer - | |
convenience init (text: String, preferences: Preferences = DriosToolTipView.globalPreferences, delegate: DriosToolTipViewDelegate? = nil) { | |
self.init(content: .text(text), preferences: preferences, delegate: delegate) | |
self.isAccessibilityElement = true | |
self.accessibilityTraits = UIAccessibilityTraits.staticText | |
self.accessibilityLabel = text | |
} | |
convenience init (contentView: UIView, preferences: Preferences = DriosToolTipView.globalPreferences, delegate: DriosToolTipViewDelegate? = nil) { | |
self.init(content: .view(contentView), preferences: preferences, delegate: delegate) | |
} | |
convenience init (text: NSAttributedString, preferences: Preferences = DriosToolTipView.globalPreferences, delegate: DriosToolTipViewDelegate? = nil) { | |
self.init(content: .attributedText(text), preferences: preferences, delegate: delegate) | |
} | |
init (content: Content, preferences: Preferences = DriosToolTipView.globalPreferences, delegate: DriosToolTipViewDelegate? = nil) { | |
self.content = content | |
self.preferences = preferences | |
self.delegate = delegate | |
super.init(frame: CGRect.zero) | |
self.backgroundColor = UIColor.clear | |
NotificationCenter.default.addObserver(self, selector: #selector(handleRotation), name: UIDevice.orientationDidChangeNotification, object: nil) | |
} | |
deinit | |
{ | |
NotificationCenter.default.removeObserver(self) | |
} | |
/** | |
NSCoding not supported. Use init(text, preferences, delegate) instead! | |
*/ | |
required init?(coder aDecoder: NSCoder) { | |
fatalError("NSCoding not supported. Use init(text, preferences, delegate) instead!") | |
} | |
// MARK: - Rotation support - | |
@objc func handleRotation() { | |
guard let sview = superview | |
, presentingView != nil else { return } | |
UIView.animate(withDuration: 0.3) { | |
self.arrange(withinSuperview: sview) | |
self.setNeedsDisplay() | |
} | |
} | |
// MARK: - Private methods - | |
private func computeFrame(arrowPosition position: ArrowPosition, refViewFrame: CGRect, superviewFrame: CGRect) -> CGRect { | |
var xOrigin: CGFloat = 0 | |
var yOrigin: CGFloat = 0 | |
switch position { | |
case .top, .any: | |
xOrigin = refViewFrame.center.x - tipViewSize.width / 2 | |
yOrigin = refViewFrame.y + refViewFrame.height | |
case .bottom: | |
xOrigin = refViewFrame.center.x - tipViewSize.width / 2 | |
yOrigin = refViewFrame.y - tipViewSize.height | |
case .right: | |
xOrigin = refViewFrame.x - tipViewSize.width | |
yOrigin = refViewFrame.center.y - tipViewSize.height / 2 | |
case .left: | |
xOrigin = refViewFrame.x + refViewFrame.width | |
yOrigin = refViewFrame.center.y - tipViewSize.height / 2 | |
} | |
var frame = CGRect(x: xOrigin, y: yOrigin, width: tipViewSize.width, height: tipViewSize.height) | |
adjustFrame(&frame, forSuperviewFrame: superviewFrame) | |
return frame | |
} | |
private func adjustFrame(_ frame: inout CGRect, forSuperviewFrame superviewFrame: CGRect) { | |
// adjust horizontally | |
if frame.x < 0 { | |
frame.x = 0 | |
} else if frame.maxX > superviewFrame.width { | |
frame.x = superviewFrame.width - frame.width | |
} | |
//adjust vertically | |
if frame.y < 0 { | |
frame.y = 0 | |
} else if frame.maxY > superviewFrame.maxY { | |
frame.y = superviewFrame.height - frame.height | |
} | |
} | |
private func isFrameValid(_ frame: CGRect, forRefViewFrame: CGRect, withinSuperviewFrame: CGRect) -> Bool { | |
return !frame.intersects(forRefViewFrame) | |
} | |
private func arrange(withinSuperview superview: UIView) { | |
var position = preferences.drawing.arrowPosition | |
let refViewFrame = presentingView!.convert(presentingView!.bounds, to: superview); | |
let superviewFrame: CGRect | |
if let scrollview = superview as? UIScrollView { | |
superviewFrame = CGRect(origin: scrollview.frame.origin, size: scrollview.contentSize) | |
} else { | |
superviewFrame = superview.frame | |
} | |
var frame = computeFrame(arrowPosition: position, refViewFrame: refViewFrame, superviewFrame: superviewFrame) | |
if !isFrameValid(frame, forRefViewFrame: refViewFrame, withinSuperviewFrame: superviewFrame) { | |
for value in ArrowPosition.allValues where value != position { | |
let newFrame = computeFrame(arrowPosition: value, refViewFrame: refViewFrame, superviewFrame: superviewFrame) | |
if isFrameValid(newFrame, forRefViewFrame: refViewFrame, withinSuperviewFrame: superviewFrame) { | |
if position != .any { | |
debugPrint("[DriosToolTipView - Info] The arrow position you chose <\(position)> could not be applied. Instead, position <\(value)> has been applied! Please specify position <\(ArrowPosition.any)> if you want DriosToolTipView to choose a position for you.") | |
} | |
frame = newFrame | |
position = value | |
preferences.drawing.arrowPosition = value | |
break | |
} | |
} | |
} | |
switch position { | |
case .bottom, .top, .any: | |
var arrowTipXOrigin: CGFloat | |
if frame.width < refViewFrame.width { | |
arrowTipXOrigin = tipViewSize.width / 2 | |
} else { | |
arrowTipXOrigin = abs(frame.x - refViewFrame.x) + refViewFrame.width / 2 | |
} | |
arrowTip = CGPoint(x: arrowTipXOrigin, y: position == .bottom ? tipViewSize.height - preferences.positioning.bubbleInsets.bottom : preferences.positioning.bubbleInsets.top) | |
case .right, .left: | |
var arrowTipYOrigin: CGFloat | |
if frame.height < refViewFrame.height { | |
arrowTipYOrigin = tipViewSize.height / 2 | |
} else { | |
arrowTipYOrigin = abs(frame.y - refViewFrame.y) + refViewFrame.height / 2 | |
} | |
arrowTip = CGPoint(x: preferences.drawing.arrowPosition == .left ? preferences.positioning.bubbleInsets.left : tipViewSize.width - preferences.positioning.bubbleInsets.right, y: arrowTipYOrigin) | |
} | |
if case .view(let contentView) = content { | |
contentView.translatesAutoresizingMaskIntoConstraints = false | |
contentView.frame = getContentRect(from: getBubbleFrame()) | |
} | |
self.frame = frame | |
} | |
// MARK:- Callbacks - | |
@objc func handleTap() { | |
self.delegate?.didTapToolTip(self) | |
guard preferences.animating.dismissOnTap else { return } | |
dismiss() | |
} | |
// MARK:- Drawing - | |
private func drawBubble(_ bubbleFrame: CGRect, arrowPosition: ArrowPosition, context: CGContext) { | |
let arrowWidth = preferences.drawing.arrowWidth | |
let arrowHeight = preferences.drawing.arrowHeight | |
let cornerRadius = preferences.drawing.cornerRadius | |
let contourPath = CGMutablePath() | |
contourPath.move(to: CGPoint(x: arrowTip.x, y: arrowTip.y)) | |
switch arrowPosition { | |
case .bottom, .top, .any: | |
contourPath.addLine(to: CGPoint(x: arrowTip.x - arrowWidth / 2, y: arrowTip.y + (arrowPosition == .bottom ? -1 : 1) * arrowHeight)) | |
if arrowPosition == .bottom { | |
drawBubbleBottomShape(bubbleFrame, cornerRadius: cornerRadius, path: contourPath) | |
} else { | |
drawBubbleTopShape(bubbleFrame, cornerRadius: cornerRadius, path: contourPath) | |
} | |
contourPath.addLine(to: CGPoint(x: arrowTip.x + arrowWidth / 2, y: arrowTip.y + (arrowPosition == .bottom ? -1 : 1) * arrowHeight)) | |
case .right, .left: | |
contourPath.addLine(to: CGPoint(x: arrowTip.x + (arrowPosition == .right ? -1 : 1) * arrowHeight, y: arrowTip.y - arrowWidth / 2)) | |
if arrowPosition == .right { | |
drawBubbleRightShape(bubbleFrame, cornerRadius: cornerRadius, path: contourPath) | |
} else { | |
drawBubbleLeftShape(bubbleFrame, cornerRadius: cornerRadius, path: contourPath) | |
} | |
contourPath.addLine(to: CGPoint(x: arrowTip.x + (arrowPosition == .right ? -1 : 1) * arrowHeight, y: arrowTip.y + arrowWidth / 2)) | |
} | |
contourPath.closeSubpath() | |
context.addPath(contourPath) | |
context.clip() | |
paintBubble(context) | |
if preferences.hasBorder { | |
drawBorder(contourPath, context: context) | |
} | |
} | |
private func drawBubbleBottomShape(_ frame: CGRect, cornerRadius: CGFloat, path: CGMutablePath) { | |
path.addArc(tangent1End: CGPoint(x: frame.x, y: frame.y + frame.height), tangent2End: CGPoint(x: frame.x, y: frame.y), radius: cornerRadius) | |
path.addArc(tangent1End: CGPoint(x: frame.x, y: frame.y), tangent2End: CGPoint(x: frame.x + frame.width, y: frame.y), radius: cornerRadius) | |
path.addArc(tangent1End: CGPoint(x: frame.x + frame.width, y: frame.y), tangent2End: CGPoint(x: frame.x + frame.width, y: frame.y + frame.height), radius: cornerRadius) | |
path.addArc(tangent1End: CGPoint(x: frame.x + frame.width, y: frame.y + frame.height), tangent2End: CGPoint(x: frame.x, y: frame.y + frame.height), radius: cornerRadius) | |
} | |
private func drawBubbleTopShape(_ frame: CGRect, cornerRadius: CGFloat, path: CGMutablePath) { | |
path.addArc(tangent1End: CGPoint(x: frame.x, y: frame.y), tangent2End: CGPoint(x: frame.x, y: frame.y + frame.height), radius: cornerRadius) | |
path.addArc(tangent1End: CGPoint(x: frame.x, y: frame.y + frame.height), tangent2End: CGPoint(x: frame.x + frame.width, y: frame.y + frame.height), radius: cornerRadius) | |
path.addArc(tangent1End: CGPoint(x: frame.x + frame.width, y: frame.y + frame.height), tangent2End: CGPoint(x: frame.x + frame.width, y: frame.y), radius: cornerRadius) | |
path.addArc(tangent1End: CGPoint(x: frame.x + frame.width, y: frame.y), tangent2End: CGPoint(x: frame.x, y: frame.y), radius: cornerRadius) | |
} | |
private func drawBubbleRightShape(_ frame: CGRect, cornerRadius: CGFloat, path: CGMutablePath) { | |
path.addArc(tangent1End: CGPoint(x: frame.x + frame.width, y: frame.y), tangent2End: CGPoint(x: frame.x, y: frame.y), radius: cornerRadius) | |
path.addArc(tangent1End: CGPoint(x: frame.x, y: frame.y), tangent2End: CGPoint(x: frame.x, y: frame.y + frame.height), radius: cornerRadius) | |
path.addArc(tangent1End: CGPoint(x: frame.x, y: frame.y + frame.height), tangent2End: CGPoint(x: frame.x + frame.width, y: frame.y + frame.height), radius: cornerRadius) | |
path.addArc(tangent1End: CGPoint(x: frame.x + frame.width, y: frame.y + frame.height), tangent2End: CGPoint(x: frame.x + frame.width, y: frame.height), radius: cornerRadius) | |
} | |
private func drawBubbleLeftShape(_ frame: CGRect, cornerRadius: CGFloat, path: CGMutablePath) { | |
path.addArc(tangent1End: CGPoint(x: frame.x, y: frame.y), tangent2End: CGPoint(x: frame.x + frame.width, y: frame.y), radius: cornerRadius) | |
path.addArc(tangent1End: CGPoint(x: frame.x + frame.width, y: frame.y), tangent2End: CGPoint(x: frame.x + frame.width, y: frame.y + frame.height), radius: cornerRadius) | |
path.addArc(tangent1End: CGPoint(x: frame.x + frame.width, y: frame.y + frame.height), tangent2End: CGPoint(x: frame.x, y: frame.y + frame.height), radius: cornerRadius) | |
path.addArc(tangent1End: CGPoint(x: frame.x, y: frame.y + frame.height), tangent2End: CGPoint(x: frame.x, y: frame.y), radius: cornerRadius) | |
} | |
private func paintBubble(_ context: CGContext) { | |
context.setFillColor(preferences.drawing.backgroundColor.cgColor) | |
context.fill(bounds) | |
} | |
private func drawBorder(_ borderPath: CGPath, context: CGContext) { | |
context.addPath(borderPath) | |
context.setStrokeColor(preferences.drawing.borderColor.cgColor) | |
context.setLineWidth(preferences.drawing.borderWidth) | |
context.strokePath() | |
} | |
private func drawText(_ bubbleFrame: CGRect, context : CGContext) { | |
guard case .text(let text) = content else { return } | |
let paragraphStyle = NSMutableParagraphStyle() | |
paragraphStyle.alignment = preferences.drawing.textAlignment | |
paragraphStyle.lineBreakMode = NSLineBreakMode.byWordWrapping | |
let textRect = getContentRect(from: bubbleFrame) | |
let attributes = [NSAttributedString.Key.font : preferences.drawing.font, NSAttributedString.Key.foregroundColor : preferences.drawing.foregroundColor, NSAttributedString.Key.paragraphStyle : paragraphStyle] | |
text.draw(in: textRect, withAttributes: attributes) | |
} | |
private func drawAttributedText(_ bubbleFrame: CGRect, context : CGContext) { | |
guard | |
case .attributedText(let text) = content | |
else { | |
return | |
} | |
let textRect = getContentRect(from: bubbleFrame) | |
text.draw(with: textRect, options: .usesLineFragmentOrigin, context: .none) | |
} | |
private func drawShadow() { | |
if preferences.hasShadow { | |
self.layer.masksToBounds = false | |
self.layer.shadowColor = preferences.drawing.shadowColor.cgColor | |
self.layer.shadowOffset = preferences.drawing.shadowOffset | |
self.layer.shadowRadius = preferences.drawing.shadowRadius | |
self.layer.shadowOpacity = Float(preferences.drawing.shadowOpacity) | |
} | |
} | |
override func draw(_ rect: CGRect) { | |
let bubbleFrame = getBubbleFrame() | |
let context = UIGraphicsGetCurrentContext()! | |
context.saveGState () | |
drawBubble(bubbleFrame, arrowPosition: preferences.drawing.arrowPosition, context: context) | |
switch content { | |
case .text: | |
drawText(bubbleFrame, context: context) | |
case .attributedText: | |
drawAttributedText(bubbleFrame, context: context) | |
case .view (let view): | |
addSubview(view) | |
} | |
drawShadow() | |
context.restoreGState() | |
} | |
private func getBubbleFrame() -> CGRect { | |
let arrowPosition = preferences.drawing.arrowPosition | |
let bubbleWidth: CGFloat | |
let bubbleHeight: CGFloat | |
let bubbleXOrigin: CGFloat | |
let bubbleYOrigin: CGFloat | |
switch arrowPosition { | |
case .bottom, .top, .any: | |
bubbleWidth = tipViewSize.width - preferences.positioning.bubbleInsets.left - preferences.positioning.bubbleInsets.right | |
bubbleHeight = tipViewSize.height - preferences.positioning.bubbleInsets.top - preferences.positioning.bubbleInsets.bottom - preferences.drawing.arrowHeight | |
bubbleXOrigin = preferences.positioning.bubbleInsets.left | |
bubbleYOrigin = arrowPosition == .bottom ? preferences.positioning.bubbleInsets.top : preferences.positioning.bubbleInsets.top + preferences.drawing.arrowHeight | |
case .left, .right: | |
bubbleWidth = tipViewSize.width - preferences.positioning.bubbleInsets.left - preferences.positioning.bubbleInsets.right - preferences.drawing.arrowHeight | |
bubbleHeight = tipViewSize.height - preferences.positioning.bubbleInsets.top - preferences.positioning.bubbleInsets.left | |
bubbleXOrigin = arrowPosition == .right ? preferences.positioning.bubbleInsets.left : preferences.positioning.bubbleInsets.left + preferences.drawing.arrowHeight | |
bubbleYOrigin = preferences.positioning.bubbleInsets.top | |
} | |
return CGRect(x: bubbleXOrigin, y: bubbleYOrigin, width: bubbleWidth, height: bubbleHeight) | |
} | |
private func getContentRect(from bubbleFrame: CGRect) -> CGRect { | |
return CGRect(x: bubbleFrame.origin.x + preferences.positioning.contentInsets.left, y: bubbleFrame.origin.y + preferences.positioning.contentInsets.top, width: contentSize.width, height: contentSize.height) | |
} | |
} | |
// MARK: - Class methods | |
extension DriosToolTipView { | |
/** | |
Presents an DriosToolTipView pointing to a particular UIBarItem instance within the specified superview | |
- parameter animated: Pass true to animate the presentation. | |
- parameter item: The UIBarButtonItem or UITabBarItem instance which the DriosToolTipView will be pointing to. | |
- parameter superview: A view which is part of the UIBarButtonItem instances superview hierarchy. Ignore this parameter in order to display the DriosToolTipView within the main window. | |
- parameter text: The text to be displayed. | |
- parameter preferences: The preferences which will configure the DriosToolTipView. | |
- parameter delegate: The delegate. | |
*/ | |
class func show(animated: Bool = true, forItem item: UIBarItem, withinSuperview superview: UIView? = nil, text: String, preferences: Preferences = DriosToolTipView.globalPreferences, delegate: DriosToolTipViewDelegate? = nil){ | |
if let view = item.view { | |
show(animated: animated, forView: view, withinSuperview: superview, text: text, preferences: preferences, delegate: delegate) | |
} | |
} | |
/** | |
Presents an DriosToolTipView pointing to a particular UIBarItem instance within the specified superview | |
- parameter animated: Pass true to animate the presentation. | |
- parameter item: The UIBarButtonItem or UITabBarItem instance which the DriosToolTipView will be pointing to. | |
- parameter superview: A view which is part of the UIBarButtonItem instances superview hierarchy. Ignore this parameter in order to display the DriosToolTipView within the main window. | |
- parameter contentView: The view to be displayed. | |
- parameter preferences: The preferences which will configure the DriosToolTipView. | |
- parameter delegate: The delegate. | |
*/ | |
class func show(animated: Bool = true, forItem item: UIBarItem, withinSuperview superview: UIView? = nil, contentView: UIView, preferences: Preferences = DriosToolTipView.globalPreferences, delegate: DriosToolTipViewDelegate? = nil){ | |
if let view = item.view { | |
show(animated: animated, forView: view, withinSuperview: superview, contentView: contentView, preferences: preferences, delegate: delegate) | |
} | |
} | |
/** | |
Presents an DriosToolTipView pointing to a particular UIView instance within the specified superview containing attributed text. | |
- parameter animated: Pass true to animate the presentation. | |
- parameter view: The UIView instance which the DriosToolTipView will be pointing to. | |
- parameter superview: A view which is part of the UIView instances superview hierarchy. Ignore this parameter in order to display the DriosToolTipView within the main window. | |
- parameter attributedText: The attributed text to be displayed. | |
- parameter preferences: The preferences which will configure the DriosToolTipView. | |
- parameter delegate: The delegate. | |
*/ | |
class func show(animated: Bool = true, forView view: UIView, withinSuperview superview: UIView? = nil, attributedText: NSAttributedString, preferences: Preferences = DriosToolTipView.globalPreferences, delegate: DriosToolTipViewDelegate? = nil){ | |
let ev = DriosToolTipView(text: attributedText, preferences: preferences, delegate: delegate) | |
ev.show(animated: animated, forView: view, withinSuperview: superview) | |
} | |
// MARK:- Instance methods - | |
/** | |
Presents an DriosToolTipView pointing to a particular UIBarItem instance within the specified superview | |
- parameter animated: Pass true to animate the presentation. | |
- parameter item: The UIBarButtonItem or UITabBarItem instance which the DriosToolTipView will be pointing to. | |
- parameter superview: A view which is part of the UIBarButtonItem instances superview hierarchy. Ignore this parameter in order to display the DriosToolTipView within the main window. | |
*/ | |
func show(animated: Bool = true, forItem item: UIBarItem, withinSuperView superview: UIView? = nil) { | |
if let view = item.view { | |
show(animated: animated, forView: view, withinSuperview: superview) | |
} | |
} | |
/** | |
Presents an DriosToolTipView pointing to a particular UIView instance within the specified superview | |
- parameter animated: Pass true to animate the presentation. | |
- parameter view: The UIView instance which the DriosToolTipView will be pointing to. | |
- parameter superview: A view which is part of the UIView instances superview hierarchy. Ignore this parameter in order to display the DriosToolTipView within the main window. | |
- parameter contentView: The view to be displayed. | |
- parameter preferences: The preferences which will configure the DriosToolTipView. | |
- parameter delegate: The delegate. | |
*/ | |
class func show(animated: Bool = true, forView view: UIView, withinSuperview superview: UIView? = nil, contentView: UIView, preferences: Preferences = DriosToolTipView.globalPreferences, delegate: DriosToolTipViewDelegate? = nil){ | |
let ev = DriosToolTipView(contentView: contentView, preferences: preferences, delegate: delegate) | |
ev.show(animated: animated, forView: view, withinSuperview: superview) | |
} | |
/** | |
Presents an DriosToolTipView pointing to a particular UIView instance within the specified superview | |
- parameter animated: Pass true to animate the presentation. | |
- parameter view: The UIView instance which the DriosToolTipView will be pointing to. | |
- parameter superview: A view which is part of the UIView instances superview hierarchy. Ignore this parameter in order to display the DriosToolTipView within the main window. | |
- parameter text: The text to be displayed. | |
- parameter preferences: The preferences which will configure the DriosToolTipView. | |
- parameter delegate: The delegate. | |
*/ | |
class func show(animated: Bool = true, forView view: UIView, withinSuperview superview: UIView? = nil, text: String, preferences: Preferences = DriosToolTipView.globalPreferences, delegate: DriosToolTipViewDelegate? = nil){ | |
let ev = DriosToolTipView(text: text, preferences: preferences, delegate: delegate) | |
ev.show(animated: animated, forView: view, withinSuperview: superview) | |
} | |
/** | |
Presents an DriosToolTipView pointing to a particular UIView instance within the specified superview | |
- parameter animated: Pass true to animate the presentation. | |
- parameter view: The UIView instance which the DriosToolTipView will be pointing to. | |
- parameter superview: A view which is part of the UIView instances superview hierarchy. Ignore this parameter in order to display the DriosToolTipView within the main window. | |
*/ | |
func show(animated: Bool = true, forView view: UIView, withinSuperview superview: UIView? = nil) { | |
#if TARGET_APP_EXTENSIONS | |
precondition(superview != nil, "The supplied superview parameter cannot be nil for app extensions.") | |
let superview = superview! | |
#else | |
precondition(superview == nil || view.hasSuperview(superview!), "The supplied superview <\(superview!)> is not a direct nor an indirect superview of the supplied reference view <\(view)>. The superview passed to this method should be a direct or an indirect superview of the reference view. To display the tooltip within the main window, ignore the superview parameter.") | |
let superview = superview ?? UIApplication.shared.windows.first! | |
#endif | |
let initialTransform = preferences.animating.showInitialTransform | |
let finalTransform = preferences.animating.showFinalTransform | |
let initialAlpha = preferences.animating.showInitialAlpha | |
let damping = preferences.animating.springDamping | |
let velocity = preferences.animating.springVelocity | |
presentingView = view | |
arrange(withinSuperview: superview) | |
transform = initialTransform | |
alpha = initialAlpha | |
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) | |
addGestureRecognizer(tap) | |
superview.addSubview(self) | |
let animations : () -> () = { | |
self.transform = finalTransform | |
self.alpha = 1 | |
} | |
if animated { | |
UIView.animate(withDuration: preferences.animating.showDuration, delay: 0, usingSpringWithDamping: damping, initialSpringVelocity: velocity, options: [.curveEaseInOut], animations: animations, completion: nil) | |
}else{ | |
animations() | |
} | |
} | |
/** | |
Dismisses the DriosToolTipView | |
- parameter completion: Completion block to be executed after the DriosToolTipView is dismissed. | |
*/ | |
func dismiss(withCompletion completion: (() -> ())? = nil) { | |
let damping = preferences.animating.springDamping | |
let velocity = preferences.animating.springVelocity | |
UIView.animate(withDuration: preferences.animating.dismissDuration, delay: 0, usingSpringWithDamping: damping, initialSpringVelocity: velocity, options: [.curveEaseInOut], animations: { | |
self.transform = self.preferences.animating.dismissTransform | |
self.alpha = self.preferences.animating.dismissFinalAlpha | |
}) { (finished) -> Void in | |
completion?() | |
self.delegate?.didDismissToolTip(self) | |
self.removeFromSuperview() | |
self.transform = CGAffineTransform.identity | |
} | |
} | |
} | |
@available(iOS 13.0.0, *) | |
extension DriosToolTipView { | |
struct DriosToolTipContent: View { | |
var item: Item | |
struct Item { | |
var title: String? | |
var description: String? | |
var image: (UIImage, size: CGSize)? | |
var actions: [ButtonAction]? | |
struct ButtonAction { | |
var key: String | |
var value: () -> Void | |
} | |
} | |
var body: some View { | |
VStack(alignment: .leading) { | |
if let image = item.image { | |
Image(uiImage: image.0) | |
.resizable() | |
.scaledToFit() | |
.frame(width: image.1.width, height: image.1.height) | |
} | |
Group { | |
if let title = item.title { | |
Text(title) | |
.fontWeight(.semibold) | |
} | |
if let description = item.description { | |
Text(description) | |
} | |
} | |
.multilineTextAlignment(.leading) | |
.foregroundColor(.white) | |
HStack { | |
Spacer() | |
if let actions = item.actions { | |
ForEach(actions, id: \.key) { action in | |
Button(action.key, action: action.value) | |
.padding(5) | |
.background(Color.white) | |
.foregroundColor(Color.green) | |
.clipShape(Capsule()) | |
.padding(3) | |
.overlay( | |
Capsule() | |
.strokeBorder(Color.white, lineWidth: 1) | |
) | |
.font(.callout) | |
} | |
} | |
} | |
} | |
.padding(8) | |
.frame(maxWidth: 400, minHeight: 25) | |
.fixedSize() | |
} | |
} | |
static func getUIView(_ content: DriosToolTipContent.Item) -> UIView { | |
let swiftuiView = DriosToolTipContent(item: content) | |
let uiView = UIHostingController(rootView: swiftuiView).view! | |
uiView.sizeToFit() | |
uiView.backgroundColor = .clear | |
return uiView | |
} | |
} | |
class DriosToolTipViewOverlay { | |
var onOnverlayTapped: (UITapGestureRecognizer) -> Void = { _ in } | |
private var visibleFrame: CGRect = .zero | |
private var overlayContainerView: UIWindow? | |
private let overlayTag = 999 | |
enum FocusStyle { | |
case square(side: CGFloat, cornerRadius: CGFloat = 1) | |
case circle(diameter: CGFloat) | |
case rectangle(size: CGSize, cornerRadius: CGFloat = 1) | |
case none | |
var size: CGSize { | |
switch self { | |
case .square(let side, _): | |
return CGSize(width: side, height: side) | |
case .circle(let diameter): | |
return CGSize(width: diameter, height: diameter) | |
case .rectangle(let size, _): | |
return size | |
case .none: | |
return .zero | |
} | |
} | |
func getPathFromCenter(_ at: CGPoint) -> UIBezierPath { | |
switch self { | |
case .square(_, let radius): | |
return UIBezierPath(roundedRect: CGRect(origin: CGPoint(x:at.x - size.width/2, y: at.y-size.height/2), | |
size: CGSize (width: size.width, height: size.height)), cornerRadius: radius) | |
case .circle: | |
return UIBezierPath ( | |
roundedRect: CGRect (origin: CGPoint(x:at.x - size.width/2, y: at.y-size.height/2), | |
size: CGSize (width: size.width, height: size.height)), cornerRadius: size.width/2) | |
case .rectangle(_, let radius): | |
return UIBezierPath(roundedRect: CGRect(origin: CGPoint(x:at.x - size.width/2, y: at.y-size.height/2), | |
size: CGSize (width: size.width, height: size.height)), cornerRadius: radius) | |
case .none: | |
return .init() | |
} | |
} | |
} | |
func getFocusFrame() -> CGRect { | |
visibleFrame | |
} | |
func create(at: CGPoint, style: FocusStyle, tapIsEnabled: Bool = false) { | |
guard let overlayContainerView else { return } | |
let blurView = UIView(frame: overlayContainerView.frame) | |
blurView.isUserInteractionEnabled = true | |
blurView.backgroundColor = .black.withAlphaComponent(0.7) | |
if tapIsEnabled { | |
let tapGesture = UITapGestureRecognizer(target: self, | |
action: #selector(onOverlayTapped)) | |
blurView.addGestureRecognizer(tapGesture) | |
} | |
overlayContainerView.addSubview(blurView) | |
let path = UIBezierPath ( | |
roundedRect: blurView.frame, | |
cornerRadius: 0) | |
let shapePath = style.getPathFromCenter(at) | |
visibleFrame = shapePath.cgPath.boundingBox | |
path.append(shapePath) | |
path.usesEvenOddFillRule = true | |
let maskLayer = CAShapeLayer() | |
maskLayer.path = path.cgPath | |
maskLayer.fillRule = CAShapeLayerFillRule.evenOdd | |
let borderLayer = CAShapeLayer() | |
borderLayer.path = shapePath.cgPath | |
borderLayer.strokeColor = UIColor.green.cgColor | |
borderLayer.lineWidth = 3 | |
blurView.layer.addSublayer(borderLayer) | |
blurView.layer.mask = maskLayer | |
blurView.tag = overlayTag | |
blurView.alpha = 0.0 | |
UIView.animate(withDuration: 0.3) { | |
blurView.alpha = 1.0 | |
} | |
} | |
@objc private func onOverlayTapped(_ gesture: UITapGestureRecognizer) { | |
onOnverlayTapped(gesture) | |
} | |
func remove() { | |
if let visualEX = overlayContainerView?.viewWithTag(overlayTag) { | |
UIView.animate(withDuration: 0.3, animations: { | |
visualEX.alpha = 0.0 | |
}) { _ in | |
visualEX.removeFromSuperview() | |
} | |
} | |
} | |
} | |
// MARK: - CGRect extension | |
extension CGRect { | |
var x: CGFloat { | |
get { origin.x } | |
set { origin.x = newValue } | |
} | |
var y: CGFloat { | |
get { origin.y } | |
set { origin.y = newValue } | |
} | |
var center: CGPoint { CGPoint(x: x + width / 2, y: y + height / 2) } | |
} | |
// MARK: - UIView extension | |
extension UIView { | |
func hasSuperview(_ superview: UIView) -> Bool{ | |
return viewHasSuperview(self, superview: superview) | |
} | |
private func viewHasSuperview(_ view: UIView, superview: UIView) -> Bool { | |
if let sview = view.superview { | |
if sview === superview { | |
return true | |
} else{ | |
return viewHasSuperview(sview, superview: superview) | |
} | |
} else{ | |
return false | |
} | |
} | |
} | |
// MARK: - UIBarItem extension | |
extension UIBarItem { | |
var view: UIView? { | |
if let item = self as? UIBarButtonItem, let customView = item.customView { | |
return customView | |
} | |
return self.value(forKey: "view") as? UIView | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment