Swift 带箭头的文本组件

作者: 半心_忬 | 来源:发表于2020-04-21 16:50 被阅读0次

    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 带箭头的文本组件

        本文链接:https://www.haomeiwen.com/subject/yhcsihtx.html