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