Skip to content

Instantly share code, notes, and snippets.

@cedricbahirwe
Last active March 21, 2024 09:53
Show Gist options
  • Save cedricbahirwe/3e39c10bcc6035b4648ff03aac008002 to your computer and use it in GitHub Desktop.
Save cedricbahirwe/3e39c10bcc6035b4648ff03aac008002 to your computer and use it in GitHub Desktop.
A Tooltip Prototype View
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