Scroll Segmented Control(Swift)

Posted Cocos2der

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Scroll Segmented Control(Swift)相关的知识,希望对你有一定的参考价值。

今天用了一个github上一个比较好用的Segmented Control但是发现不是我要效果,我需要支持scrollView。当栏目数量超过一屏幕,需要能够滑动。

由于联系作者没有回复,我就自己在其基础上增加了下scrollView的支持。

代码比较简单,直接在UIControl下写的。

其中有一个比较有意思的地方,IndicatorView下面放了一个titleMaskView作为mask。用来遮罩选用的titles标签。已达到过渡效果。

源代码:

//
//  SwiftySegmentedControl.swift
//  SwiftySegmentedControl
//
//  Created by LiuYanghui on 2017/1/10.
//  Copyright © 2017年 Yanghui.Liu. All rights reserved.
//

import UIKit

// MARK: - SwiftySegmentedControl
@IBDesignable open class SwiftySegmentedControl: UIControl 
    // MARK: IndicatorView
    fileprivate class IndicatorView: UIView 
        // MARK: Properties
        fileprivate let titleMaskView = UIView()
        fileprivate let line = UIView()
        fileprivate let lineHeight: CGFloat = 2.0
        fileprivate var cornerRadius: CGFloat = 0 
            didSet 
                layer.cornerRadius = cornerRadius
                titleMaskView.layer.cornerRadius = cornerRadius
            
        
        override open var frame: CGRect 
            didSet 
                titleMaskView.frame = frame
                let lineFrame = CGRect(x: 0, y: frame.size.height - lineHeight, width: frame.size.width, height: lineHeight)
                line.frame = lineFrame
            
        

        open var lineColor = UIColor.clear 
            didSet 
                line.backgroundColor = lineColor
            
        

        // MARK: Lifecycle
        init() 
            super.init(frame: CGRect.zero)
            finishInit()
        
        required init?(coder aDecoder: NSCoder) 
            super.init(coder: aDecoder)
            finishInit()
        
        fileprivate func finishInit() 
            layer.masksToBounds = true
            titleMaskView.backgroundColor = UIColor.black
            addSubview(line)
        

        override open func layoutSubviews() 
            super.layoutSubviews()

        
    

    // MARK: Constants
    fileprivate struct Animation 
        fileprivate static let withBounceDuration: TimeInterval = 0.3
        fileprivate static let springDamping: CGFloat = 0.75
        fileprivate static let withoutBounceDuration: TimeInterval = 0.2
    
    fileprivate struct Color 
        fileprivate static let background: UIColor = UIColor.white
        fileprivate static let title: UIColor = UIColor.black
        fileprivate static let indicatorViewBackground: UIColor = UIColor.black
        fileprivate static let selectedTitle: UIColor = UIColor.white
    

    // MARK: Error handling
    public enum IndexError: Error 
        case indexBeyondBounds(UInt)
    

    // MARK: Properties
    /// The selected index
    public fileprivate(set) var index: UInt
    /// The titles / options available for selection
    public var titles: [String] 
        get 
            let titleLabels = titleLabelsView.subviews as! [UILabel]
            return titleLabels.map  $0.text! 
        
        set 
            guard newValue.count > 1 else 
                return
            
            let labels: [(UILabel, UILabel)] = newValue.map 
                (string) -> (UILabel, UILabel) in

                let titleLabel = UILabel()
                titleLabel.textColor = titleColor
                titleLabel.text = string
                titleLabel.lineBreakMode = .byTruncatingTail
                titleLabel.textAlignment = .center
                titleLabel.font = titleFont
                titleLabel.layer.borderWidth = titleBorderWidth
                titleLabel.layer.borderColor = titleBorderColor
                titleLabel.layer.cornerRadius = indicatorView.cornerRadius

                let selectedTitleLabel = UILabel()
                selectedTitleLabel.textColor = selectedTitleColor
                selectedTitleLabel.text = string
                selectedTitleLabel.lineBreakMode = .byTruncatingTail
                selectedTitleLabel.textAlignment = .center
                selectedTitleLabel.font = selectedTitleFont

                return (titleLabel, selectedTitleLabel)
            

            titleLabelsView.subviews.forEach( $0.removeFromSuperview() )
            selectedTitleLabelsView.subviews.forEach( $0.removeFromSuperview() )

            for (inactiveLabel, activeLabel) in labels 
                titleLabelsView.addSubview(inactiveLabel)
                selectedTitleLabelsView.addSubview(activeLabel)
            

            setNeedsLayout()
        
    
    /// Whether the indicator should bounce when selecting a new index. Defaults to true
    public var bouncesOnChange = true
    /// Whether the the control should always send the .ValueChanged event, regardless of the index remaining unchanged after interaction. Defaults to false
    public var alwaysAnnouncesValue = false
    /// Whether to send the .ValueChanged event immediately or wait for animations to complete. Defaults to true
    public var announcesValueImmediately = true
    /// Whether the the control should ignore pan gestures. Defaults to false
    public var panningDisabled = false
    /// The control's and indicator's corner radii
    @IBInspectable public var cornerRadius: CGFloat 
        get 
            return layer.cornerRadius
        
        set 
            layer.cornerRadius = newValue
            indicatorView.cornerRadius = newValue - indicatorViewInset
            titleLabels.forEach  $0.layer.cornerRadius = indicatorView.cornerRadius 
        
    
    /// The indicator view's background color
    @IBInspectable public var indicatorViewBackgroundColor: UIColor? 
        get 
            return indicatorView.backgroundColor
        
        set 
            indicatorView.backgroundColor = newValue
        
    
    /// Margin spacing between titles. Default to 33.
    @IBInspectable public var marginSpace: CGFloat = 33 
        didSet  setNeedsLayout() 
    
    /// The indicator view's inset. Defaults to 2.0
    @IBInspectable public var indicatorViewInset: CGFloat = 2.0 
        didSet  setNeedsLayout() 
    
    /// The indicator view's border width
    public var indicatorViewBorderWidth: CGFloat 
        get 
            return indicatorView.layer.borderWidth
        
        set 
            indicatorView.layer.borderWidth = newValue
        
    
    /// The indicator view's border width
    public var indicatorViewBorderColor: CGColor? 
        get 
            return indicatorView.layer.borderColor
        
        set 
            indicatorView.layer.borderColor = newValue
        
    
    /// The indicator view's line color
    public var indicatorViewLineColor: UIColor 
        get 
            return indicatorView.lineColor
        
        set 
            indicatorView.lineColor = newValue
        
    
    /// The text color of the non-selected titles / options
    @IBInspectable public var titleColor: UIColor  
        didSet 
            titleLabels.forEach  $0.textColor = titleColor 
        
    
    /// The text color of the selected title / option
    @IBInspectable public var selectedTitleColor: UIColor 
        didSet 
            selectedTitleLabels.forEach  $0.textColor = selectedTitleColor 
        
    
    /// The titles' font
    public var titleFont: UIFont = UILabel().font 
        didSet 
            titleLabels.forEach  $0.font = titleFont 
        
    
    /// The selected title's font
    public var selectedTitleFont: UIFont = UILabel().font 
        didSet 
            selectedTitleLabels.forEach  $0.font = selectedTitleFont 
        
    
    /// The titles' border width
    public var titleBorderWidth: CGFloat = 0.0 
        didSet 
            titleLabels.forEach  $0.layer.borderWidth = titleBorderWidth 
        
    
    /// The titles' border color
    public var titleBorderColor: CGColor = UIColor.clear.cgColor 
        didSet 
            titleLabels.forEach  $0.layer.borderColor = titleBorderColor 
        
    

    // MARK: - Private properties
    fileprivate let contentScrollView: UIScrollView = 
        let scrollView = UIScrollView()
        scrollView.showsVerticalScrollIndicator = false
        scrollView.showsHorizontalScrollIndicator = false
        return scrollView
    ()
    fileprivate let titleLabelsView = UIView()
    fileprivate let selectedTitleLabelsView = UIView()
    fileprivate let indicatorView = IndicatorView()
    fileprivate var initialIndicatorViewFrame: CGRect?

    fileprivate var tapGestureRecognizer: UITapGestureRecognizer!
    fileprivate var panGestureRecognizer: UIPanGestureRecognizer!

    fileprivate var width: CGFloat  return bounds.width 
    fileprivate var height: CGFloat  return bounds.height 
    fileprivate var titleLabelsCount: Int  return titleLabelsView.subviews.count 
    fileprivate var titleLabels: [UILabel]  return titleLabelsView.subviews as! [UILabel] 
    fileprivate var selectedTitleLabels: [UILabel]  return selectedTitleLabelsView.subviews as! [UILabel] 
    fileprivate var totalInsetSize: CGFloat  return indicatorViewInset * 2.0 
    fileprivate lazy var defaultTitles: [String] =  return ["First", "Second"] ()
    fileprivate var titlesWidth: [CGFloat] 
        return titles.map 
            let statusLabelText: NSString = $0 as NSString
            let size = CGSize(width: width, height: height - totalInsetSize)
            let dic = NSDictionary(object: titleFont,
                                   forKey: NSFontAttributeName as NSCopying)
            let strSize = statusLabelText.boundingRect(with: size,
                                                       options: .usesLineFragmentOrigin,
                                                       attributes: dic as? [String : AnyObject],
                                                       context: nil).size
            return strSize.width
        
    

    // MARK: Lifecycle
    required public init?(coder aDecoder: NSCoder) 
        index = 0
        titleColor = Color.title
        selectedTitleColor = Color.selectedTitle
        super.init(coder: aDecoder)
        titles = defaultTitles
        finishInit()
    
    public init(frame: CGRect,
                titles: [String],
                index: UInt,
                backgroundColor: UIColor,
                titleColor: UIColor,
                indicatorViewBackgroundColor: UIColor,
                selectedTitleColor: UIColor) 
        self.index = index
        self.titleColor = titleColor
        self.selectedTitleColor = selectedTitleColor
        super.init(frame: frame)
        self.titles = titles
        self.backgroundColor = backgroundColor
        self.indicatorViewBackgroundColor = indicatorViewBackgroundColor
        finishInit()
    

    @available(*, deprecated, message: "Use init(frame:titles:index:backgroundColor:titleColor:indicatorViewBackgroundColor:selectedTitleColor:) instead.")
    convenience override public init(frame: CGRect) 
        self.init(frame: frame,
                  titles: ["First", "Second"],
                  index: 0,
                  backgroundColor: Color.background,
                  titleColor: Color.title,
                  indicatorViewBackgroundColor: Color.indicatorViewBackground,
                  selectedTitleColor: Color.selectedTitle)
    

    @available(*, unavailable, message: "Use init(frame:titles:index:backgroundColor:titleColor:indicatorViewBackgroundColor:selectedTitleColor:) instead.")
    convenience init() 
        self.init(frame: CGRect.zero,
                  titles: ["First", "Second"],
                  index: 0,
                  backgroundColor: Color.background,
                  titleColor: Color.title,
                  indicatorViewBackgroundColor: Color.indicatorViewBackground,
                  selectedTitleColor: Color.selectedTitle)
    


    fileprivate func finishInit() 
        layer.masksToBounds = true

        addSubview(contentScrollView)
        contentScrollView.addSubview(titleLabelsView)
        contentScrollView.addSubview(indicatorView)
        contentScrollView.addSubview(selectedTitleLabelsView)
        selectedTitleLabelsView.layer.mask = indicatorView.titleMaskView.layer

        tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(SwiftySegmentedControl.tapped(_:)))
        addGestureRecognizer(tapGestureRecognizer)

        panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(SwiftySegmentedControl.panned(_:)))
        panGestureRecognizer.delegate = self
        addGestureRecognizer(panGestureRecognizer)
    
    override open func layoutSubviews() 
        super.layoutSubviews()
        guard titleLabelsCount > 1 else 
            return
        

        contentScrollView.frame = bounds
        let allElementsWidth = titlesWidth.reduce(0, $0 + $1) + CGFloat(titleLabelsCount) * marginSpace
        contentScrollView.contentSize = CGSize(width: max(allElementsWidth, width), height: 0)

        titleLabelsView.frame = bounds
        selectedTitleLabelsView.frame = bounds

        indicatorView.frame = elementFrame(forIndex: index)

        for index in 0...titleLabelsCount-1 
            let frame = elementFrame(forIndex: UInt(index))
            titleLabelsView.subviews[index].frame = frame
            selectedTitleLabelsView.subviews[index].frame = frame
        
    

    // MARK: Index Setting
    /*!
     Sets the control's index.

     - parameter index:    The new index
     - parameter animated: (Optional) Whether the change should be animated or not. Defaults to true.

     - throws: An error of type IndexBeyondBounds(UInt) is thrown if an index beyond the available indices is passed.
     */
    public func setIndex(_ index: UInt, animated: Bool = true) throws 
        guard titleLabels.indices.contains(Int(index)) else 
            throw IndexError.indexBeyondBounds(index)
        
        let oldIndex = self.index
        self.index = index
        moveIndicatorViewToIndex(animated, shouldSendEvent: (self.index != oldIndex || alwaysAnnouncesValue))
        fixedScrollViewOffset(Int(self.index))
    

    // MARK: Fixed ScrollView offset
    fileprivate func fixedScrollViewOffset(_ focusIndex: Int) 
        guard contentScrollView.contentSize.width > width else 
            return
        

        let targetMidX = self.titleLabels[Int(self.index)].frame.midX
        let offsetX = contentScrollView.contentOffset.x
        let addOffsetX = targetMidX - offsetX - width / 2
        let newOffSetX = min(max(0, offsetX + addOffsetX), contentScrollView.contentSize.width - width)
        let point = CGPoint(x: newOffSetX, y: contentScrollView.contentOffset.y)
        contentScrollView.setContentOffset(point, animated: true)
    

    // MARK: Animations
    fileprivate func moveIndicatorViewToIndex(_ animated: Bool, shouldSendEvent: Bool) 
        if animated 
            if shouldSendEvent && announcesValueImmediately 
                sendActions(for: .valueChanged)
            
            UIView.animate(withDuration: bouncesOnChange ? Animation.withBounceDuration : Animation.withoutBounceDuration,
                           delay: 0.0,
                           usingSpringWithDamping: bouncesOnChange ? Animation.springDamping : 1.0,
                           initialSpringVelocity: 0.0,
                           options: [UIViewAnimationOptions.beginFromCurrentState, UIViewAnimationOptions.curveEaseOut],
                           animations: 
                            () -> Void in
                            self.moveIndicatorView()
            , completion:  (finished) -> Void in
                if finished && shouldSendEvent && !self.announcesValueImmediately 
                    self.sendActions(for: .valueChanged)
                
            )
         else 
            moveIndicatorView()
            sendActions(for: .valueChanged)
        
    

    // MARK: Helpers
    fileprivate func elementFrame(forIndex index: UInt) -> CGRect 
        // 计算出label的宽度,label宽度 = (text宽度) + marginSpace
        // | <= 0.5 * marginSpace => text1 <= 0.5 * marginSpace => | <= 0.5 * marginSpace => text2 <= 0.5 * marginSpace => |
        // 如果总宽度小于bunds.width,则均分宽度 label宽度 = bunds.width / count
        let allElementsWidth = titlesWidth.reduce(0, $0 + $1) + CGFloat(titleLabelsCount) * marginSpace
        if allElementsWidth < width 
            let elementWidth = (width - totalInsetSize) / CGFloat(titleLabelsCount)
            return CGRect(x: CGFloat(index) * elementWidth + indicatorViewInset,
                          y: indicatorViewInset,
                          width: elementWidth,
                          height: height - totalInsetSize)
         else 
            let titlesWidth = self.titlesWidth
            let frontTitlesWidth = titlesWidth.enumerated().reduce(CGFloat(0))  (total, current) in
                return current.0 < Int(index) ? total + current.1 : total
            
            let x = frontTitlesWidth + CGFloat(index) * marginSpace
            return CGRect(x: x,
                          y: indicatorViewInset,
                          width: titlesWidth[Int(index)] + marginSpace,
                          height: height - totalInsetSize)
        
    
    fileprivate func nearestIndex(toPoint point: CGPoint) -> UInt 
        let distances = titleLabels.map  abs(point.x - $0.center.x) 
        return UInt(distances.index(of: distances.min()!)!)
    
    fileprivate func moveIndicatorView() 
        indicatorView.frame = titleLabels[Int(self.index)].frame
        layoutIfNeeded()
    

    // MARK: Action handlers
    @objc fileprivate func tapped(_ gestureRecognizer: UITapGestureRecognizer!) 
        let location = gestureRecognizer.location(in: contentScrollView)
        try! setIndex(nearestIndex(toPoint: location))
    
    @objc fileprivate func panned(_ gestureRecognizer: UIPanGestureRecognizer!) 
        guard !panningDisabled else 
            return
        

        switch gestureRecognizer.state 
        case .began:
            initialIndicatorViewFrame = indicatorView.frame
        case .changed:
            var frame = initialIndicatorViewFrame!
            frame.origin.x += gestureRecognizer.translation(in: self).x
            frame.origin.x = max(min(frame.origin.x, bounds.width - indicatorViewInset - frame.width), indicatorViewInset)
            indicatorView.frame = frame
        case .ended, .failed, .cancelled:
            try! setIndex(nearestIndex(toPoint: indicatorView.center))
        default: break
        
    


// MARK: - UIGestureRecognizerDelegate
extension SwiftySegmentedControl: UIGestureRecognizerDelegate 
    override open func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool 
        if gestureRecognizer == panGestureRecognizer 
            return indicatorView.frame.contains(gestureRecognizer.location(in: contentScrollView))
        
        return super.gestureRecognizerShouldBegin(gestureRecognizer)
    

使用方式

fileprivate func setupControl() 
        let viewSegmentedControl = SwiftySegmentedControl(
            frame: CGRect(x: 0.0, y: 430.0, width: view.bounds.width, height: 50.0),
            titles: ["All", "New", "Pictures", "One", "Two", "Three", "Four", "Five", "Six", "Artists", "Albums", "Recent"],
            index: 1,
            backgroundColor: UIColor(red:0.11, green:0.12, blue:0.13, alpha:1.00),
            titleColor: .white,
            indicatorViewBackgroundColor: UIColor(red:0.11, green:0.12, blue:0.13, alpha:1.00),
            selectedTitleColor: UIColor(red:0.97, green:0.00, blue:0.24, alpha:1.00))
        viewSegmentedControl.autoresizingMask = [.flexibleWidth]
        viewSegmentedControl.indicatorViewInset = 0
        viewSegmentedControl.cornerRadius = 0.0
        viewSegmentedControl.titleFont = UIFont(name: "HelveticaNeue", size: 16.0)!
        viewSegmentedControl.selectedTitleFont = UIFont(name: "HelveticaNeue", size: 16.0)!
        viewSegmentedControl.bouncesOnChange = false
        // 是否禁止拖动选择,注意,这里有个问题我还没改,如果titles长度超过1屏幕,或者说,你想使用下划线,建议禁止拖动选择。
        viewSegmentedControl.panningDisabled = true 
        // 下划线颜色。默认透明
        viewSegmentedControl.indicatorViewLineColor = UIColor.red
        view.addSubview(viewSegmentedControl)
    

Github: SwiftySegmentedControl

以上是关于Scroll Segmented Control(Swift)的主要内容,如果未能解决你的问题,请参考以下文章

《iOS Human Interface Guidelines》——Segmented Control

SwiftUI Segmented Control 在视图刷新时选择段文本动画

控制 3 分段

mui的选项卡js选中指定项

Segmented 标签栏 切换效果

Segmented Button 的应用