Skip to content

Instantly share code, notes, and snippets.

@koingdev
Created June 26, 2020 07:34
Show Gist options
  • Save koingdev/4076802dc4a72da6a01427d8ff04f250 to your computer and use it in GitHub Desktop.
Save koingdev/4076802dc4a72da6a01427d8ff04f250 to your computer and use it in GitHub Desktop.
Simple UIView class to display tooltip in iOS
/// Display ToolTip View
///
/// Example:
/// ```swift
/// let paragraph = NSMutableParagraphStyle()
/// paragraph.lineSpacing = 18
/// let attributes = [
/// NSAttributedString.Key.font : UIFont.systemFont(ofSize: 16),
/// NSAttributedString.Key.foregroundColor : UIColor.white,
/// NSAttributedString.Key.paragraphStyle : paragraph
/// ]
/// let preference = ToolTipView.Preference(backgroundColor: .darkGray, attributes: attributes, arrowPosition: .top)
/// let toolTip = ToolTipView(text: "ToolTip Message", preference: preference, sender: sender, parent: self.view)
/// toolTip.show(withDuration: 0.7)
/// ```
final class ToolTipView: UIView {
enum ArrowPosition {
case top
case bottom
case left
case right
}
struct Preference {
let backgroundColor: UIColor
let attributes: [NSAttributedString.Key : Any]
let arrowPosition: ArrowPosition
let radius: CGFloat = 8
let arrowWidth: CGFloat = 10
let arrowHeight: CGFloat = 5
let borderWidth: CGFloat = 1
let borderColor: UIColor = .white
let maxWidth: CGFloat = 300
let padding: CGFloat = 20
var initialTransform = CGAffineTransform(scaleX: 0, y: 0)
var initialAlpha: CGFloat = 0
init(backgroundColor: UIColor, attributes: [NSAttributedString.Key : Any], arrowPosition: ArrowPosition) {
self.backgroundColor = backgroundColor
self.attributes = attributes
self.arrowPosition = arrowPosition
}
}
// MARK: Properties
var dismissOnTap: Bool = true
private var text: String!
private var preference: Preference!
private weak var sender: UIView?
private weak var parent: UIView?
private let titleLabel = UILabel()
private lazy var contentSize: CGSize = { [unowned self] in
var textSize = self.text.boundingRect(with: CGSize(width: self.preference.maxWidth, height: .greatestFiniteMagnitude), options: .usesLineFragmentOrigin, attributes: self.preference.attributes, context: nil).size
textSize.width = ceil(textSize.width) + preference.padding * 2
textSize.height = ceil(textSize.height) + preference.padding * 2
if textSize.width < self.preference.arrowWidth {
textSize.width = self.preference.arrowWidth
}
return textSize
}()
// MARK: Init
init(text: String, preference: Preference, sender: UIView, parent: UIView) {
self.text = text
self.preference = preference
self.sender = sender
self.parent = parent
super.init(frame: .zero)
configure()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
// MARK: Convenience Methods
static func show(text: String, preference: Preference, sender: UIView, parent: UIView, duration: Double) {
let toolTip = ToolTipView(text: text, preference: preference, sender: sender, parent: parent)
toolTip.show(withDuration: duration)
}
func show(withDuration duration: Double, completion: (()->())? = nil) {
let parent = self.parent ?? UIApplication.shared.windows.first!
if dismissOnTap {
let tap = UITapGestureRecognizer(target: self, action: #selector(didTap))
addGestureRecognizer(tap)
}
transform = preference.initialTransform
alpha = preference.initialAlpha
parent.addSubview(self)
let animations : () -> () = {
self.transform = .identity
self.alpha = 1
}
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.7, options: [.curveEaseInOut, .allowUserInteraction], animations: animations) { _ in completion?() }
}
func dismiss(withDuration duration: Double = 0.3, completion: (()->())? = nil) {
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.7, options: [.curveEaseInOut, .allowUserInteraction], animations: {
self.alpha = 0
}) { _ in
self.removeFromSuperview()
self.transform = CGAffineTransform.identity
completion?()
}
}
// MARK: Private
private func configure() {
backgroundColor = .clear
addSubview(titleLabel)
titleLabel.numberOfLines = 0
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.leftAnchor.constraint(equalTo: leftAnchor, constant: preference.padding).isActive = true
titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: preference.padding).isActive = true
titleLabel.rightAnchor.constraint(equalTo: rightAnchor, constant: -preference.padding).isActive = true
titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: -preference.padding).isActive = true
titleLabel.attributedText = NSAttributedString(string: text, attributes: preference.attributes)
self.frame = calculateFrame()
// Notification
#if swift(>=4.2)
let notificationName = UIDevice.orientationDidChangeNotification
#else
let notificationName = NSNotification.Name.UIDeviceOrientationDidChange
#endif
NotificationCenter.default.addObserver(self, selector: #selector(handleRotation), name: notificationName, object: nil)
}
private func calculateFrame() -> CGRect {
guard let sender = sender, let parent = parent else { return .zero }
var xOrigin: CGFloat = 0
var yOrigin: CGFloat = 0
let senderFrame = sender.convert(sender.bounds, to: parent)
let parentFrame = parent.frame
switch preference.arrowPosition {
case .top:
xOrigin = senderFrame.center.x - contentSize.width / 2
yOrigin = senderFrame.y + senderFrame.height
case .bottom:
xOrigin = senderFrame.center.x - contentSize.width / 2
yOrigin = senderFrame.y - contentSize.height
case .right:
xOrigin = senderFrame.x - contentSize.width
yOrigin = senderFrame.y - contentSize.height / 2
case .left:
xOrigin = senderFrame.x + senderFrame.width
yOrigin = senderFrame.y - contentSize.height / 2
}
var newFrame = CGRect(x: xOrigin, y: yOrigin, width: contentSize.width, height: contentSize.height)
// Validate
if newFrame.x < 0 {
newFrame.x = 0
} else if newFrame.maxX > parentFrame.width {
newFrame.x = parentFrame.width - newFrame.width
}
if newFrame.y < 0 {
newFrame.y = 0
} else if frame.maxY > parentFrame.maxY {
newFrame.y = parentFrame.height - newFrame.height
}
return newFrame
}
@objc private func didTap() {
dismiss()
}
@objc private func handleRotation() {
guard sender != nil else { return }
UIView.animate(withDuration: 0.3) {
self.frame = self.calculateFrame()
self.setNeedsDisplay()
}
}
// MARK: Drawing
override func draw(_ rect: CGRect) {
let top: CGFloat = rect.minY + preference.arrowHeight
let left: CGFloat = rect.minX + preference.arrowWidth
let right: CGFloat = rect.maxX - preference.arrowWidth
let bottom: CGFloat = rect.maxY - preference.arrowHeight - preference.borderWidth
let topLeft = CGPoint(x: left + preference.radius, y: top + preference.radius)
let topRight = CGPoint(x: right - preference.radius, y: top + preference.radius)
let bottomLeft = CGPoint(x: left + preference.radius, y: bottom - preference.radius)
let bottomRight = CGPoint(x: right - preference.radius, y: bottom - preference.radius)
let middleArrowPoint: CGFloat = 0.5 // Arrow will be in the middle
let path = UIBezierPath()
path.addArc(withCenter: topLeft, radius: preference.radius, startAngle: .pi, endAngle: 3 * .pi / 2, clockwise: true)
switch preference.arrowPosition {
case .top:
let centerX = rect.width * middleArrowPoint
path.addLine(to: CGPoint(x: centerX - preference.arrowWidth / 2, y: top))
path.addLine(to: CGPoint(x: centerX, y: rect.minY))
path.addLine(to: CGPoint(x: centerX + preference.arrowWidth / 2 , y: top))
default: break
}
path.addLine(to: CGPoint(x: topRight.x, y: top))
path.addArc(withCenter: topRight, radius: preference.radius, startAngle: -.pi / 2, endAngle: 0, clockwise: true)
switch preference.arrowPosition {
case .right:
let centerY = rect.height * middleArrowPoint
path.addLine(to: CGPoint(x: right, y: centerY - preference.arrowHeight / 2))
path.addLine(to: CGPoint(x: rect.maxX, y: centerY))
path.addLine(to: CGPoint(x: right, y: centerY + preference.arrowHeight / 2))
default: break
}
path.addLine(to: CGPoint(x: right, y: bottomRight.y))
path.addArc(withCenter: bottomRight, radius: preference.radius, startAngle: 0, endAngle: .pi / 2, clockwise: true)
switch preference.arrowPosition {
case .bottom:
let centerX = rect.width * middleArrowPoint
path.addLine(to: CGPoint(x: centerX + preference.arrowWidth / 2, y: bottom))
path.addLine(to: CGPoint(x: centerX, y: rect.maxY))
path.addLine(to: CGPoint(x: centerX - preference.arrowWidth / 2 , y: bottom))
default: break
}
path.addLine(to: CGPoint(x: bottomLeft.x, y: bottom))
path.addArc(withCenter: bottomLeft, radius: preference.radius, startAngle: .pi / 2, endAngle: .pi, clockwise: true)
switch preference.arrowPosition {
case .left:
let centerY = rect.height * middleArrowPoint
path.addLine(to: CGPoint(x: left, y: centerY + preference.arrowHeight / 2))
path.addLine(to: CGPoint(x: rect.minX, y: centerY))
path.addLine(to: CGPoint(x: left, y: centerY - preference.arrowHeight / 2))
default: break
}
path.addLine(to: CGPoint(x: left, y: topLeft.y))
preference.backgroundColor.setFill()
preference.borderColor.setStroke()
path.lineWidth = preference.borderWidth
path.fill()
path.stroke()
}
}
fileprivate extension CGRect {
var x: CGFloat {
get {
return origin.x
}
set {
origin.x = newValue
}
}
var y: CGFloat {
get {
return origin.y
}
set {
origin.y = newValue
}
}
var center: CGPoint {
return CGPoint(x: x + width / 2, y: y + height / 2)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment