UI
设计中,一些文本经常会出现三角箭头,类似于下图:
单独某个场景,画箭头、边框及阴影实现起来也还好,不过呢,UI的设计在不同的场景下,会出现一些微调,比如:
- 箭头的大小、位置
- 边框的粗细、颜色、阴影
- 内容的填充颜色
- 文本与边框的内边距
- 文本的字体、对齐方式、颜色、根据文本自适应高度
- 文本显示为富文本
出于便利和组件的通用化,于是封装了一个带箭头的文本组件。
一、使用
使用该组件实现文章开始那张图片的效果。
1.初始化:
BXArrowLabel
是继承自UIView
的,使用初始化UIView
的方式即可;然后定制化开放的配置属性,如果有一些不确定的属性,需要根据数据来判断的话,放到配置数据之前设置即可。
/// 箭头在上的Label
let arrowLableTop = BXArrowLabel().then {
// 设置四个角的圆角值
$0.cornerRadius = 8
// 四个角的圆角一致,推荐使用cornerRadius,不一致时分别设置
// $0.cornerSize.topLeft = 8
// $0.cornerSize.bottomLeft = 8
// $0.cornerSize.topRight = 8
// $0.cornerSize.bottomRight = 8
// 设置箭头大小
$0.arrowSize = (6, 14)
// 箭头起始的偏移值
$0.arrowOffset = 10
// 箭头位置为在上面
$0.arrowPosition = .top
// 设置需要阴影
$0.isNeedShadow = true
// 设置文本的内边距
$0.textOffset = UIEdgeInsets(top: 8, left: 12, bottom: -8, right: -12)
}
Tips:这里有几个自定义属性需要说明一下:
1.箭头的位置,通过设置
arrowPosition
来指定,支持上下左右四个方向2.箭头的大小,比如设置
arrowSize
为(6, 14)
,6
指的是箭头三角中垂线的长度,14
指的是尽头三角底边的长度-
3.箭头起始的偏移值
a.比如设置
arrowOffset
为10
,10
是调用者根据自己的业务计算出来的值,组件对这个值的计算是需要刨去圆角的直径的,比如向下的箭头,是从左下的圆角直径值开始计算的
b.arrowOffset
是UInt
类型,所以箭头最小的位置是从对应的圆角直径开始的,箭头最大的位置做了判断,最大能到的位置为另一侧的圆角直径所在的位置 4.圆角,如果四个角的圆角值一致,则使用
cornerRadius
设置即可,如果四个圆角的值不一致,则使用cornerSize
分别设置
2.布局:
使用frame
或SnapKit
都可以,我这里是使用SnapKit
。
- 文本是根据内容自适应的,所以不用设置高度
view.addSubview(arrowLableTop)
arrowLableTop.snp.makeConstraints {
$0.left.equalTo(40)
$0.width.equalTo(UIScreen.main.bounds.size.width - 80)
$0.centerY.equalToSuperview().offset(80)
}
3.配置数据:
BXArrowLabel
支持普通文本和富文本。
普通文本:
arrowLableTop.setupText("网络支付反欺诈、套现安全风控措施加强,客户使用微信在线支付,受到不同程度的限制(金额限制或完全无法支付)")
富文本:
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 5
// 统一控制 文字大小(14),行间距(5),段落间距(5),统一字体颜色(666666)
let attributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 14),
NSAttributedString.Key.foregroundColor: UIColor.hipac.colorWithHex(hexString: "666666"),
NSAttributedString.Key.paragraphStyle: paragraph]
let attrStr: NSAttributedString = NSAttributedString(string: "网络支付反欺诈、套现安全风控措施加强,客户使用微信在线支付,受到不同程度的限制(金额限制或完全无法支付)", attributes: attributes)
// 配置新圆角
arrowLableTop.cornerRadius = 16
// 配置新阴影颜色
arrowLableTop.shadowColor = UIColor.green.withAlphaComponent(0.5)
arrowLableTop.setupAttributeText(attrStr)
Tips:在调用配置文本之前,所有的配置均可更改,比如一些配置需要根据服务端返回数据解析后才知道,在调用setupText
或setupAttributeText
前,修改配置即可。
二、开放的配置化属性
为了更有效地应对UI
的细小微调,比如如下的一些效果:
- 普通文本
- 富文本
BXArrowLabel
开放了如下几类属性配置:
- 箭头相关的属性
/// 箭头位置,默认箭头在底部
public var arrowPosition: ArrowPosition = .bottom
/// 箭头大小,默认(6, 14)【箭头高度,箭头宽度】,支持设置为(0, 0)
public var arrowSize: (CGFloat, CGFloat) = (6, 14)
/// 箭头偏移量,默认0(水平方向箭头,从左边开始计算,垂直方向箭头,则从上边开始计算),用UInt,避免做负值的判断
public var arrowOffset: UInt = 0
- 圆角相关的属性
/// 圆角值(四个角的圆角一样的话,使用这个值),默认为 nil
public var cornerRadius: CGFloat?
/// 四个角圆角值,默认都是0
public var cornerSize: CornerSize = CornerSize(topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0)
- 带三角的layer相关的属性
/// 填充颜色,默认白色
public var fillColor: UIColor = .white
/// 线条颜色,默认淡灰
public var strokeColor: UIColor = .lightGray
/// 线条宽度,默认 1
public var lineWidth: CGFloat = 1
- 文本相关的属性
/// 文本偏移(默认,上下左右的偏移均为 0)
public var textOffset: UIEdgeInsets = .zero
/// 文本颜色,默认淡灰
public var textColor: UIColor = .lightGray
/// 对齐方式,默认居左
public var textAlignment: NSTextAlignment = .left
/// 文本行数,默认多行自适应
public var textNumberOfLines: Int = 0
/// 文本字体,默认系统12号字体
public var textFont: UIFont = UIFont.systemFont(ofSize: 12)
- 阴影相关的属性
/// 是否需要阴影,默认不需要
public var isNeedShadow: Bool = false
/// 阴影质量,默认1
public var shadowOpacity: Float = 1
/// 阴影颜色,默认淡灰
public var shadowColor: UIColor = .lightGray
/// 阴影圆角,默认6
public var shadowRadius: CGFloat = 6
/// 阴影偏移量,默认(0, 2)
public var shadowOffset: CGSize = CGSize(width: 0, height: 2)
三、设置文本的方法
BXArrowLabel
提供两个方法配置文本,一个是普通文本,一个是富文本,如下所示:
/// 设置文本
/// - Parameter text: 文本
func setupText(_ text: String) {
lblTips.text = text
configView()
}
/// 设置富文本
/// - Parameter text: 文本
func setupAttributeText(_ attributeText: NSAttributedString) {
lblTips.attributedText = attributeText
configView()
}
四、实现思路
实现思路就是在自定义View上加一个contentView
,然后在contentView
放一个CAShapeLayer
,然后将一个UILabel
放在contentView
上,基于contentView
绘制三角。
剩下的就是根据不同的配置进行计算和绘制。
五、遗留问题
BXArrowLabel
是支持后续持续修改属性和内容的,但在测试中发现一个问题,有解决方案的话,望不吝赐教:
设置了富文本之后,再设置普通文本,自适应不生效!!!
六、后续优化点
可以再新增一个参数,一个参考视图,基于这个参考视图,计算出箭头三角锚点的定位,方便调用者调用。
七、源码
import UIKit
/*
支持的配置化属性:
1.箭头的大小和位置
2.边框圆角、宽度、颜色和阴影
3.文本内容的配置
*/
/// 带箭头的label
public class BXArrowLabel: UIView {
/// 箭头位置
public enum ArrowPosition {
/// 底部
case bottom
/// 头部
case top
/// 左侧
case left
/// 右侧
case right
}
/// 圆角尺寸
public struct CornerSize {
var topLeft: CGFloat
var topRight: CGFloat
var bottomLeft: CGFloat
var bottomRight: CGFloat
init(topLeft: CGFloat, topRight: CGFloat, bottomLeft: CGFloat, bottomRight: CGFloat) {
self.topLeft = topLeft
self.topRight = topRight
self.bottomLeft = bottomLeft
self.bottomRight = bottomRight
}
}
// MARK: 箭头相关的属性
/// 箭头位置,默认箭头在底部
public var arrowPosition: ArrowPosition = .bottom
/// 箭头大小,默认(6, 14)【箭头高度,箭头宽度】,支持设置为(0, 0)
public var arrowSize: (CGFloat, CGFloat) = (6, 14)
/// 箭头偏移量,默认0(水平方向箭头,从左边开始计算,垂直方向箭头,则从上边开始计算),用UInt,避免做负值的判断
public var arrowOffset: UInt = 0
// MARK: 圆角相关的属性
/// 圆角值(四个角的圆角一样的话,使用这个值),默认为 nil
public var cornerRadius: CGFloat?
/// 四个角圆角值,默认都是0
public var cornerSize: CornerSize = CornerSize(topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0)
// MARK: 带三角的layer相关的属性
/// 填充颜色,默认白色
public var fillColor: UIColor = .white
/// 线条颜色,默认淡灰
public var strokeColor: UIColor = .lightGray
/// 线条宽度,默认 1
public var lineWidth: CGFloat = 1
// MARK: 文本相关的属性
/// 文本偏移(默认,上下左右的偏移均为 0)
public var textOffset: UIEdgeInsets = .zero
/// 文本颜色,默认淡灰
public var textColor: UIColor = .lightGray
/// 对齐方式,默认居左
public var textAlignment: NSTextAlignment = .left
/// 文本行数,默认多行自适应
public var textNumberOfLines: Int = 0
/// 文本字体,默认系统12号字体
public var textFont: UIFont = UIFont.systemFont(ofSize: 12)
// MARK: 阴影相关的属性
/// 是否需要阴影,默认不需要
public var isNeedShadow: Bool = false
/// 阴影质量,默认1
public var shadowOpacity: Float = 1
/// 阴影颜色,默认淡灰
public var shadowColor: UIColor = .lightGray
/// 阴影圆角,默认6
public var shadowRadius: CGFloat = 6
/// 阴影偏移量,默认(0, 2)
public var shadowOffset: CGSize = CGSize(width: 0, height: 2)
/// 箭头开始的位置,默认0(水平方向箭头,从左边开始计算,垂直方向箭头,则从上边开始计算)
private lazy var arrowStartPosition: CGFloat = CGFloat(arrowOffset)
/// 容器
private var contentView: UIView = UIView()
/// 文本内容
private var lblTips: UILabel = UILabel().then {
$0.font = UIFont.systemFont(ofSize: 12)
$0.textColor = .lightGray
$0.numberOfLines = 0
$0.textAlignment = .left
}
/// layer
private var shapeLayer: CAShapeLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: UI
private extension BXArrowLabel {
/// 设置UI
func setupUI() {
addSubview(contentView)
contentView.layer.addSublayer(shapeLayer)
contentView.addSubview(lblTips)
layoutViews()
}
/// 布局
func layoutViews() {
contentView.snp.makeConstraints {
$0.top.left.right.equalToSuperview()
$0.bottom.equalTo(lblTips.snp.bottom).offset(arrowSize.0 - textOffset.bottom)
}
lblTips.snp.makeConstraints {
$0.top.equalToSuperview().offset(textOffset.top)
$0.left.equalToSuperview().offset(textOffset.left)
$0.right.equalToSuperview().offset(textOffset.right)
$0.bottom.equalToSuperview().offset(-arrowSize.0)
}
}
/// 配置 shapeLayer
func configShapeLayer() {
// 需要拿到具体bounds,才能画圆角
layoutIfNeeded()
if let radius = cornerRadius {
cornerSize = CornerSize(topLeft: radius, topRight: radius, bottomLeft: radius, bottomRight: radius)
}
var borderBounds = contentView.bounds
switch arrowPosition {
case .top, .bottom:
borderBounds.size.height = borderBounds.size.height - arrowSize.0
case .left, .right:
borderBounds.size.width = borderBounds.size.width - arrowSize.0
}
let path = fetchPathWithRect(bounds: borderBounds)
shapeLayer.fillColor = fillColor.cgColor
shapeLayer.strokeColor = strokeColor.cgColor
shapeLayer.lineWidth = lineWidth
shapeLayer.path = path.cgPath
// 需要阴影的话
if isNeedShadow {
shapeLayer.shadowPath = path.cgPath
shapeLayer.shadowOpacity = shadowOpacity
shapeLayer.shadowColor = shadowColor.cgColor
shapeLayer.shadowRadius = shadowRadius
shapeLayer.shadowOffset = shadowOffset
}
}
}
// MARK: public method
public extension BXArrowLabel {
/// 设置文本
/// - Parameter text: 文本
func setupText(_ text: String) {
lblTips.text = text
configView()
}
// TODO: 这个有个问题,设置了富文本之后,再设置普通文本,自适应不生效,暂时不知道什么原因 @山竹
/// 设置富文本
/// - Parameter text: 文本
func setupAttributeText(_ attributeText: NSAttributedString) {
lblTips.attributedText = attributeText
configView()
}
}
// MARK: private method
private extension BXArrowLabel {
/// 配置view
func configView() {
updateTextProperty()
updateSubViewConstraints()
configShapeLayer()
}
/// 根据配置更新约束
func updateSubViewConstraints() {
contentView.snp.updateConstraints {
$0.top.equalToSuperview().offset(arrowPosition == .top ? arrowSize.0 + textOffset.top : 0)
$0.left.equalToSuperview().offset(arrowPosition == .left ? arrowSize.0 + textOffset.left : 0)
$0.right.equalToSuperview().offset(arrowPosition == .right ? -arrowSize.0 + textOffset.right : 0)
$0.bottom.equalTo(lblTips.snp.bottom).offset(arrowPosition == .bottom ? arrowSize.0 - textOffset.bottom : -textOffset.bottom)
}
lblTips.snp.updateConstraints {
$0.top.equalToSuperview().offset(arrowPosition == .top ? arrowSize.0 + textOffset.top : textOffset.top)
$0.left.equalToSuperview().offset(arrowPosition == .left ? arrowSize.0 + textOffset.left : textOffset.left)
$0.right.equalToSuperview().offset(arrowPosition == .right ? -arrowSize.0 + textOffset.right : textOffset.right)
$0.bottom.equalToSuperview().offset(arrowPosition == .bottom ? -arrowSize.0 + textOffset.bottom : textOffset.bottom)
}
}
/// 更新文本属性
func updateTextProperty() {
lblTips.font = textFont
lblTips.textColor = textColor
lblTips.textAlignment = textAlignment
lblTips.numberOfLines = textNumberOfLines
}
/// 根据contentView的bounds及配置属性画出贝泽尔曲线
/// - Parameter bounds: contentView的bounds
/// - Returns: 贝泽尔曲线
func fetchPathWithRect(bounds: CGRect) -> UIBezierPath {
let minX = bounds.minX + (arrowPosition == .left ? arrowSize.0 : 0)
let minY = bounds.minY + (arrowPosition == .top ? arrowSize.0 : 0)
let maxX = bounds.maxX + (arrowPosition == .left ? arrowSize.0 : 0)
let maxY = bounds.maxY + (arrowPosition == .top ? arrowSize.0 : 0)
calculateCorrectArrowStartPosition(maxX: maxX, maxY: maxY)
// 左上圆心
let topLeftCenterPoint = CGPoint(x: minX + cornerSize.topLeft,
y: minY + cornerSize.topLeft)
// 左下圆心
let bottomLeftCenterPoint = CGPoint(x: minX + cornerSize.bottomLeft,
y: maxY - cornerSize.bottomLeft)
// 右上圆心
let topRightCenterPoint = CGPoint(x: maxX - cornerSize.topRight,
y: minY + cornerSize.topRight)
// 右下圆心
let bottomRightCenterPoint = CGPoint(x: maxX - cornerSize.bottomRight,
y: maxY - cornerSize.bottomRight)
let path = UIBezierPath()
path.move(to: CGPoint(x: topLeftCenterPoint.x, y: minY))
// 左上圆角
path.addArc(withCenter: CGPoint(x: topLeftCenterPoint.x, y: topLeftCenterPoint.y), radius: cornerSize.topLeft, startAngle: CGFloat.pi / 2 * 3, endAngle: CGFloat.pi, clockwise: false)
// 左边箭头
if arrowPosition == .left {
path.addLine(to: CGPoint(x: minX, y: topLeftCenterPoint.y + arrowStartPosition))
path.addLine(to: CGPoint(x: minX - arrowSize.0, y: topLeftCenterPoint.y + arrowStartPosition + arrowSize.1 / 2))
path.addLine(to: CGPoint(x: minX, y: topLeftCenterPoint.y + arrowStartPosition + arrowSize.1))
}
path.addLine(to: CGPoint(x: minX, y: bottomLeftCenterPoint.y))
// 左下圆角
path.addArc(withCenter: CGPoint(x: bottomLeftCenterPoint.x, y: bottomLeftCenterPoint.y), radius: cornerSize.bottomLeft, startAngle: CGFloat.pi, endAngle: CGFloat.pi / 2, clockwise: false)
// 底部箭头
if arrowPosition == .bottom {
path.addLine(to: CGPoint(x: bottomLeftCenterPoint.x + arrowStartPosition, y: maxY))
path.addLine(to: CGPoint(x: bottomLeftCenterPoint.x + arrowStartPosition + arrowSize.1 / 2, y: maxY + arrowSize.0))
path.addLine(to: CGPoint(x: bottomLeftCenterPoint.x + arrowStartPosition + arrowSize.1, y: maxY))
}
path.addLine(to: CGPoint(x: bottomRightCenterPoint.x, y: maxY))
// 右下圆角
path.addArc(withCenter: CGPoint(x: bottomRightCenterPoint.x, y: bottomRightCenterPoint.y), radius: cornerSize.bottomRight, startAngle: CGFloat.pi / 2, endAngle: 0, clockwise: false)
// 右侧箭头
if arrowPosition == .right {
path.addLine(to: CGPoint(x: maxX, y: topRightCenterPoint.y + arrowStartPosition + arrowSize.1))
path.addLine(to: CGPoint(x: maxX + arrowSize.0, y: topRightCenterPoint.y + arrowStartPosition + arrowSize.1 / 2))
path.addLine(to: CGPoint(x: maxX, y: topRightCenterPoint.y + arrowStartPosition))
}
path.addLine(to: CGPoint(x: maxX, y: topRightCenterPoint.y))
// 右上圆角
path.addArc(withCenter: CGPoint(x: topRightCenterPoint.x, y: topRightCenterPoint.y), radius: cornerSize.topRight, startAngle: 0, endAngle: CGFloat.pi / 2 * 3, clockwise: false)
// 顶部箭头
if arrowPosition == .top {
path.addLine(to: CGPoint(x: arrowStartPosition + bottomLeftCenterPoint.x + arrowSize.1, y: minY))
path.addLine(to: CGPoint(x: arrowStartPosition + bottomLeftCenterPoint.x + arrowSize.1 / 2, y: minY - arrowSize.0))
path.addLine(to: CGPoint(x: arrowStartPosition + bottomLeftCenterPoint.x, y: minY))
}
path.close()
return path
}
/// 计算正确的开始箭头偏移值
/// - Parameters:
/// - maxX: 最大x值
/// - maxY: 最大y值
func calculateCorrectArrowStartPosition(maxX: CGFloat, maxY: CGFloat) {
switch arrowPosition {
case .bottom:
if arrowStartPosition > maxX - cornerSize.bottomLeft - cornerSize.bottomRight - arrowSize.1 {
arrowStartPosition = maxX - cornerSize.bottomLeft - cornerSize.bottomRight - arrowSize.1
}
case .top:
if arrowStartPosition > maxX - cornerSize.topLeft - cornerSize.topRight - arrowSize.1 {
arrowStartPosition = maxX - cornerSize.topLeft - cornerSize.topRight - arrowSize.1
}
case .left:
if arrowStartPosition > maxY - cornerSize.topLeft - cornerSize.bottomLeft - arrowSize.1 {
arrowStartPosition = maxY - cornerSize.topLeft - cornerSize.bottomLeft - arrowSize.1
}
case .right:
if arrowStartPosition > maxY - cornerSize.topRight - cornerSize.bottomRight - arrowSize.1 {
arrowStartPosition = maxY - cornerSize.topRight - cornerSize.bottomRight - arrowSize.1
}
}
}
}