BetterSegmentedControl.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. //
  2. // BetterSegmentedControl.swift
  3. //
  4. // Created by George Marmaridis on 01/04/16.
  5. // Copyright © 2016 George Marmaridis. All rights reserved.
  6. //
  7. import Foundation
  8. @IBDesignable open class BetterSegmentedControl: UIControl {
  9. private class IndicatorView: UIView {
  10. // MARK: Properties
  11. fileprivate let segmentMaskView = UIView()
  12. fileprivate var cornerRadius: CGFloat = 0 {
  13. didSet {
  14. layer.cornerRadius = cornerRadius
  15. segmentMaskView.layer.cornerRadius = cornerRadius
  16. }
  17. }
  18. override open var frame: CGRect {
  19. didSet {
  20. segmentMaskView.frame = frame
  21. }
  22. }
  23. // MARK: Lifecycle
  24. init() {
  25. super.init(frame: CGRect.zero)
  26. finishInit()
  27. }
  28. required init?(coder aDecoder: NSCoder) {
  29. super.init(coder: aDecoder)
  30. finishInit()
  31. }
  32. private func finishInit() {
  33. layer.masksToBounds = true
  34. segmentMaskView.backgroundColor = .black
  35. }
  36. }
  37. // MARK: Constants
  38. private struct Animation {
  39. static let withBounceDuration: TimeInterval = 0.3
  40. static let springDamping: CGFloat = 0.75
  41. static let withoutBounceDuration: TimeInterval = 0.2
  42. }
  43. // MARK: Properties
  44. /// The selected index
  45. public var index: UInt
  46. /// The segments available for selection
  47. public var segments: [BetterSegmentedControlSegment] {
  48. didSet {
  49. guard segments.count > 1 else {
  50. return
  51. }
  52. normalSegmentsView.subviews.forEach({ $0.removeFromSuperview() })
  53. selectedSegmentsView.subviews.forEach({ $0.removeFromSuperview() })
  54. for segment in segments {
  55. normalSegmentsView.addSubview(segment.normalView)
  56. selectedSegmentsView.addSubview(segment.selectedView)
  57. }
  58. setNeedsLayout()
  59. }
  60. }
  61. /// A list of options to configure the control with
  62. public var options: [BetterSegmentedControlOption]? {
  63. get { return nil }
  64. set {
  65. guard let options = newValue else {
  66. return
  67. }
  68. for option in options {
  69. switch option {
  70. case let .indicatorViewBackgroundColor(value):
  71. indicatorViewBackgroundColor = value
  72. case let .indicatorViewInset(value):
  73. indicatorViewInset = value
  74. case let .indicatorViewBorderWidth(value):
  75. indicatorViewBorderWidth = value
  76. case let .indicatorViewBorderColor(value):
  77. indicatorViewBorderColor = value
  78. case let .alwaysAnnouncesValue(value):
  79. alwaysAnnouncesValue = value
  80. case let .announcesValueImmediately(value):
  81. announcesValueImmediately = value
  82. case let .panningDisabled(value):
  83. panningDisabled = value
  84. case let .backgroundColor(value):
  85. backgroundColor = value
  86. case let .cornerRadius(value):
  87. cornerRadius = value
  88. case let .bouncesOnChange(value):
  89. bouncesOnChange = value
  90. }
  91. }
  92. }
  93. }
  94. /// Whether the indicator should bounce when selecting a new index. Defaults to true
  95. @IBInspectable public var bouncesOnChange: Bool = true
  96. /// Whether the the control should always send the .ValueChanged event, regardless of the index remaining unchanged after interaction. Defaults to false
  97. @IBInspectable public var alwaysAnnouncesValue: Bool = false
  98. /// Whether to send the .ValueChanged event immediately or wait for animations to complete. Defaults to true
  99. @IBInspectable public var announcesValueImmediately: Bool = true
  100. /// Whether the the control should ignore pan gestures. Defaults to false
  101. @IBInspectable public var panningDisabled: Bool = false
  102. /// The control's and indicator's corner radii
  103. @IBInspectable public var cornerRadius: CGFloat {
  104. get {
  105. return layer.cornerRadius
  106. }
  107. set {
  108. layer.cornerRadius = newValue
  109. indicatorView.cornerRadius = newValue - indicatorViewInset
  110. segmentViews.forEach { $0.layer.cornerRadius = indicatorView.cornerRadius }
  111. }
  112. }
  113. /// The indicator view's background color
  114. @IBInspectable public var indicatorViewBackgroundColor: UIColor? {
  115. get {
  116. return indicatorView.backgroundColor
  117. }
  118. set {
  119. indicatorView.backgroundColor = newValue
  120. }
  121. }
  122. /// The indicator view's inset. Defaults to 2.0
  123. @IBInspectable public var indicatorViewInset: CGFloat = 2.0 {
  124. didSet { setNeedsLayout() }
  125. }
  126. /// The indicator view's border width
  127. @IBInspectable public var indicatorViewBorderWidth: CGFloat {
  128. get {
  129. return indicatorView.layer.borderWidth
  130. }
  131. set {
  132. indicatorView.layer.borderWidth = newValue
  133. }
  134. }
  135. /// The indicator view's border color
  136. @IBInspectable public var indicatorViewBorderColor: UIColor? {
  137. get {
  138. guard let color = indicatorView.layer.borderColor else {
  139. return nil
  140. }
  141. return UIColor(cgColor: color)
  142. }
  143. set {
  144. indicatorView.layer.borderColor = newValue?.cgColor
  145. }
  146. }
  147. // MARK: Private properties
  148. private let normalSegmentsView = UIView()
  149. private let selectedSegmentsView = UIView()
  150. private let indicatorView = IndicatorView()
  151. private var initialIndicatorViewFrame: CGRect?
  152. private var tapGestureRecognizer: UITapGestureRecognizer!
  153. private var panGestureRecognizer: UIPanGestureRecognizer!
  154. private var width: CGFloat { return bounds.width }
  155. private var height: CGFloat { return bounds.height }
  156. private var normalSegmentCount: Int { return normalSegmentsView.subviews.count }
  157. private var normalSegments: [UIView] { return normalSegmentsView.subviews }
  158. private var selectedSegments: [UIView] { return selectedSegmentsView.subviews }
  159. private var segmentViews: [UIView] { return normalSegments + selectedSegments}
  160. private var totalInsetSize: CGFloat { return indicatorViewInset * 2.0 }
  161. private lazy var defaultSegments: [BetterSegmentedControlSegment] = {
  162. return [LabelSegment(text: "First"), LabelSegment(text: "Second")]
  163. }()
  164. // MARK: Lifecycle
  165. public init(frame: CGRect,
  166. segments: [BetterSegmentedControlSegment],
  167. index: UInt = 0,
  168. options: [BetterSegmentedControlOption]? = nil) {
  169. self.index = index
  170. self.segments = segments
  171. super.init(frame: frame)
  172. completeInit()
  173. self.options = options
  174. }
  175. required public init?(coder aDecoder: NSCoder) {
  176. self.index = 0
  177. self.segments = [LabelSegment(text: "First"), LabelSegment(text: "Second")]
  178. super.init(coder: aDecoder)
  179. completeInit()
  180. }
  181. @available(*, unavailable, message: "Use init(frame:segments:index:options:) instead.")
  182. convenience override public init(frame: CGRect) {
  183. self.init(frame: frame,
  184. segments: [LabelSegment(text: "First"), LabelSegment(text: "Second")])
  185. }
  186. @available(*, unavailable, message: "Use init(frame:segments:index:options:) instead.")
  187. convenience init() {
  188. self.init(frame: .zero,
  189. segments: [LabelSegment(text: "First"), LabelSegment(text: "Second")])
  190. }
  191. private func completeInit() {
  192. layer.masksToBounds = true
  193. normalSegmentsView.clipsToBounds = true
  194. addSubview(normalSegmentsView)
  195. addSubview(indicatorView)
  196. selectedSegmentsView.clipsToBounds = true
  197. addSubview(selectedSegmentsView)
  198. selectedSegmentsView.layer.mask = indicatorView.segmentMaskView.layer
  199. tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(BetterSegmentedControl.tapped(_:)))
  200. addGestureRecognizer(tapGestureRecognizer)
  201. panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(BetterSegmentedControl.panned(_:)))
  202. panGestureRecognizer.delegate = self
  203. addGestureRecognizer(panGestureRecognizer)
  204. guard segments.count > 1 else { return }
  205. for segment in segments {
  206. segment.normalView.clipsToBounds = true
  207. normalSegmentsView.addSubview(segment.normalView)
  208. segment.selectedView.clipsToBounds = true
  209. selectedSegmentsView.addSubview(segment.selectedView)
  210. }
  211. setNeedsLayout()
  212. }
  213. override open func layoutSubviews() {
  214. super.layoutSubviews()
  215. guard normalSegmentCount > 1 else {
  216. return
  217. }
  218. normalSegmentsView.frame = bounds
  219. selectedSegmentsView.frame = bounds
  220. indicatorView.frame = elementFrame(forIndex: index)
  221. for index in 0...normalSegmentCount-1 {
  222. let frame = elementFrame(forIndex: UInt(index))
  223. normalSegmentsView.subviews[index].frame = frame
  224. selectedSegmentsView.subviews[index].frame = frame
  225. }
  226. }
  227. open override func prepareForInterfaceBuilder() {
  228. super.prepareForInterfaceBuilder()
  229. setDefaultLabelTextSegmentColorsFromInterfaceBuilder()
  230. }
  231. open override func awakeFromNib() {
  232. super.awakeFromNib()
  233. setDefaultLabelTextSegmentColorsFromInterfaceBuilder()
  234. }
  235. private func setDefaultLabelTextSegmentColorsFromInterfaceBuilder() {
  236. guard let normalLabelSegments = normalSegments as? [UILabel],
  237. let selectedLabelSegments = selectedSegments as? [UILabel] else {
  238. return
  239. }
  240. normalLabelSegments.forEach {
  241. $0.textColor = indicatorView.backgroundColor
  242. }
  243. selectedLabelSegments.forEach {
  244. $0.textColor = backgroundColor
  245. }
  246. }
  247. // MARK: Index Setting
  248. /// Sets the control's index.
  249. ///
  250. /// - Parameters:
  251. /// - index: The new index
  252. /// - animated: (Optional) Whether the change should be animated or not. Defaults to true.
  253. public func setIndex(_ index: UInt, animated: Bool = true) {
  254. guard normalSegments.indices.contains(Int(index)) else {
  255. return
  256. }
  257. let oldIndex = self.index
  258. self.index = index
  259. moveIndicatorViewToIndex(animated, shouldSendEvent: (self.index != oldIndex || alwaysAnnouncesValue))
  260. }
  261. // MARK: Indicator View Customization
  262. /// Adds the passed view as a subview to the indicator view
  263. ///
  264. /// - Parameter view: The view to be added to the indicator view
  265. public func addSubviewToIndicator(_ view: UIView) {
  266. indicatorView.addSubview(view)
  267. }
  268. // MARK: Animations
  269. private func moveIndicatorViewToIndex(_ animated: Bool, shouldSendEvent: Bool) {
  270. if animated {
  271. if shouldSendEvent && announcesValueImmediately {
  272. sendActions(for: .valueChanged)
  273. }
  274. UIView.animate(withDuration: bouncesOnChange ? Animation.withBounceDuration : Animation.withoutBounceDuration,
  275. delay: 0.0,
  276. usingSpringWithDamping: bouncesOnChange ? Animation.springDamping : 1.0,
  277. initialSpringVelocity: 0.0,
  278. options: [UIView.AnimationOptions.beginFromCurrentState, UIView.AnimationOptions.curveEaseOut],
  279. animations: {
  280. () -> Void in
  281. self.moveIndicatorView()
  282. }, completion: { (finished) -> Void in
  283. if finished && shouldSendEvent && !self.announcesValueImmediately {
  284. self.sendActions(for: .valueChanged)
  285. }
  286. })
  287. } else {
  288. moveIndicatorView()
  289. sendActions(for: .valueChanged)
  290. }
  291. }
  292. // MARK: Helpers
  293. private func elementFrame(forIndex index: UInt) -> CGRect {
  294. let elementWidth = (width - totalInsetSize) / CGFloat(normalSegmentCount)
  295. return CGRect(x: CGFloat(index) * elementWidth + indicatorViewInset,
  296. y: indicatorViewInset,
  297. width: elementWidth,
  298. height: height - totalInsetSize)
  299. }
  300. private func nearestIndex(toPoint point: CGPoint) -> UInt {
  301. let distances = normalSegments.map { abs(point.x - $0.center.x) }
  302. return UInt(distances.index(of: distances.min()!)!)
  303. }
  304. private func moveIndicatorView() {
  305. indicatorView.frame = normalSegments[Int(self.index)].frame
  306. layoutIfNeeded()
  307. }
  308. // MARK: Action handlers
  309. @objc private func tapped(_ gestureRecognizer: UITapGestureRecognizer!) {
  310. let location = gestureRecognizer.location(in: self)
  311. setIndex(nearestIndex(toPoint: location))
  312. }
  313. @objc private func panned(_ gestureRecognizer: UIPanGestureRecognizer!) {
  314. guard !panningDisabled else {
  315. return
  316. }
  317. switch gestureRecognizer.state {
  318. case .began:
  319. initialIndicatorViewFrame = indicatorView.frame
  320. case .changed:
  321. var frame = initialIndicatorViewFrame!
  322. frame.origin.x += gestureRecognizer.translation(in: self).x
  323. frame.origin.x = max(min(frame.origin.x, bounds.width - indicatorViewInset - frame.width), indicatorViewInset)
  324. indicatorView.frame = frame
  325. case .ended, .failed, .cancelled:
  326. setIndex(nearestIndex(toPoint: indicatorView.center))
  327. default: break
  328. }
  329. }
  330. }
  331. // MARK: - UIGestureRecognizerDelegate
  332. extension BetterSegmentedControl: UIGestureRecognizerDelegate {
  333. override open func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
  334. if gestureRecognizer == panGestureRecognizer {
  335. return indicatorView.frame.contains(gestureRecognizer.location(in: self))
  336. }
  337. return super.gestureRecognizerShouldBegin(gestureRecognizer)
  338. }
  339. }