今天用了一个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