Swift 带箭头的文本组件

UI设计中,一些文本经常会出现三角箭头,类似于下图:

2.png

单独某个场景,画箭头、边框及阴影实现起来也还好,不过呢,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.比如设置arrowOffset1010是调用者根据自己的业务计算出来的值,组件对这个值的计算是需要刨去圆角的直径的,比如向下的箭头,是从左下的圆角直径值开始计算的
    b.arrowOffsetUInt类型,所以箭头最小的位置是从对应的圆角直径开始的,箭头最大的位置做了判断,最大能到的位置为另一侧的圆角直径所在的位置

  • 4.圆角,如果四个角的圆角值一致,则使用cornerRadius设置即可,如果四个圆角的值不一致,则使用cornerSize分别设置

2.布局:

使用frameSnapKit都可以,我这里是使用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:在调用配置文本之前,所有的配置均可更改,比如一些配置需要根据服务端返回数据解析后才知道,在调用setupTextsetupAttributeText前,修改配置即可。

二、开放的配置化属性

为了更有效地应对UI的细小微调,比如如下的一些效果:

  • 普通文本
1.png
  • 富文本
3.png

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
            }
        }
    }
}

你可能感兴趣的:(Swift 带箭头的文本组件)