Created
June 26, 2020 07:34
-
-
Save koingdev/4076802dc4a72da6a01427d8ff04f250 to your computer and use it in GitHub Desktop.
Simple UIView class to display tooltip in iOS
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
/// 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