自定义UISegmentedControl

UI要求实现一个性别选择,很明显就是UISegementedControl,只是外观需要自己修改实现一下,第一反应就想着去使用UISegementedControl了(当然你也可以自定义View去实现)

本文很浅薄地接涉及到了KVO ,KVC 等Runtime机制

xx

UI如上图所示,任务清单如下

  1. Segment
    1. 文字
    2. 边框
    3. 背景
    4. (选中和未选中两个状态)
  2. Segment之间的间距

消除原生效果

把蓝色前景和白色背景都改掉

genderSegmentedControl.tintColor = .clear
genderSegmentedControl.backgroundColor = .black

Segment

title

title自定义比较简单,查一下UISegementedControl的文档,调用接口就完事了

open func setTitleTextAttributes(_ attributes: [NSAttributedString.Key : Any]?, for state: UIControl.State)

边框&背景

既然我们用的是UISegementedControl,盲猜它的subviews也是UISegement,可惜查UIKit里面并没有这个类,看来这是个私有类,不能用.(现在只是猜测subview是什么类,后文去验证)

但是不管它是什么类,它都是UIView的子类就对了,UIView能设置边框border和背景background,那就把它的子类当成UIView用就完事了

for segment in self.subviews {
segment.layer.borderWidth = 1
segment.layer.borderColor = UIColor.white.cgColor
// 这里是给UIview的layer底层添加了一层layer当做背景
segment.setDiagonalBackground(show: !segmentIsSelected)
}

现在边框和背景都设置了,但是不能随着状态改变,后续需要绑定状态

间距

查文档,UISegementedControl还是不能直接设置间距,只有一个设置分割图片的方法

func setDividerImage(_ dividerImage: UIImage?, forLeftSegmentState leftState: UIControl.State, rightSegmentState rightState: UIControl.State, barMetrics: UIBarMetrics)

那就自己生成图片,设置好尺寸,实现间距自定义

fileprivate extension UIImage {
class func render(size: CGSize, _ draw: () -> Void) -> UIImage? {
UIGraphicsBeginImageContext(size)
defer { UIGraphicsEndImageContext() }

draw()

return UIGraphicsGetImageFromCurrentImageContext()?
.withRenderingMode(.alwaysTemplate)
}
}

依照api的功能,根据两边的不同状态设置不同的分割图片,这里全部设置为透明的图片

for leftState in [UIControl.State.normal,.selected] {
for rightState in [UIControl.State.normal,.selected] {
self.setDividerImage(UIImage.render(size: CGSize(width: 16, height: 24), {
UIColor.clear.setFill()
}), forLeftSegmentState: leftState, rightSegmentState: rightState, barMetrics: .default)
}
}

添加观察

做到这里,只是一个静态的选择控件,还需要根据选中的Segment去改变UI

初步设想

观察整体: UISegmentedControl.selectedSementIndex

这个属性是可以直接访问到的,只要这个有变化,咱们就去重新设置subviews的外观

既然有索引index,根据index去找到subviews中选中的Segment,重新设置外观就完成了

(但是索引对应的UISegment和你直观的思想是冲突的,处理起来逻辑会很混乱)

后续的想法:

观察局部: Segment.isSelected

如果说每一个Segment本身都有一个属性isSelected,那直接监听此属性,让Segment自己改变外观就好了. 接下来就需要去验证我们的想法

UISegementedControl的subview是什么

新建一个playground,进行验证

import UIKit

let a = UISegmentedControl(items: ["a","b"])
a.selectedSegmentIndex = 1
let b = a.subviews.first!
print(b)
//输出如下
//<UISegment: 0x7fd1ce4118f0; frame = (23 0; 22 29); opaque = NO; layer = <CALayer: 0x600000b77680>>

确实是一个UISegment,也确实是一个私有类,文档没有给出此类的任何接口,无法直接调用

Segment 是否有 isSelected 属性

UISegment 是私有类,没有文档可看,只能借助runtime机制去看一下它有什么属性
成员变量与属性的区别

for x in a.subviews { // a 是 UISegmentedControl
print("------ivar-----")
var pcount:UInt32 = 0 // 计数用
var prs = class_copyPropertyList(x.classForCoder, &pcount) //拷贝类的属性列表
// 遍历列表,输出
for i in 0..<Int(pcount) {
let ivar = String(cString: ivar_getName(prs![i])!, encoding: .utf8)
let property = String(cString: property_getName(prs![i]), encoding: .utf8)
print(ivar! + " " + property!)
}
}

//输出如下
/*
------ivar-----
T@"NSArray",C,N,S_setInfoConstraints:,V_infoConstraints _infoConstraints
TB,GisSelected selected
TB,GisMomentary momentary
Ti controlSize
T@"NSString",C,N badgeValue
T@"UIView",R badgeView
Td,N,V_requestedScaleFactor requestedScaleFactor
*/

selected就是我们需要的属性,打印出来看看是不是布尔型(其实是Int)

for x in a.subviews {
let n = x.value(forKey: "isSelected")!
print(n)
print(n is Int)
}

//输出
/*
1
true
0
true
*/

由此可知,选中为1,未选中为0

添加监听

再新建一个playground,做一个小demo,测试一下

//: A UIKit based Playground for presenting user interface

import UIKit
import PlaygroundSupport

class MyViewController : UIViewController {
override func loadView() {
let view = UIView()
self.view = view
}

let sc = UISegmentedControl(items: ["first","second","third"])

override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black

view.addSubview(sc)
sc.frame.origin = CGPoint(x: 100, y: 100)
// 监听UISegementedControl
sc.addObserver(self, forKeyPath: "selectedSegmentIndex", options: .new, context: nil)
// 监听 UISegement
for x in sc.subviews {
x.addObserver(self, forKeyPath: "selected", options: .new, context: nil)
}
}

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
let value = change![.newKey]! as! Int
// 对于UISegmentedControl,当选中的Segment变化时,输出index
if keyPath == "selectedSegmentIndex" {
print("current selected:\(value)")
}else if keyPath == "selected"{ //对于UISegement,自身选中时背景变白,未选中时变黄,由于未设置初始值,那么初始是蓝色/透明背景
if value == 1 {
(object as! UIView).backgroundColor = .white // sc的tintColor忘记改了,默认为前景为蓝色
}else {
(object as! UIView).backgroundColor = .yellow
}
}
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

cut

封装控件

既然功能上可行,那就封装好一个自定义UISegementedControl,方便使用,其实仅仅是对外观的修改,功能没有任何变化

import UIKit

class TaoSegmentedControl: UISegmentedControl {

private init() {
super.init(frame: .zero)
config()
}

private override init(frame: CGRect) {
super.init(frame: frame)
}

override init(items:[Any]?) {
super.init(items: items)
config()
for segment in subviews {
segment.addObserver(self, forKeyPath: "selected", options: [.new,.initial], context: nil)
}
}

required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

private func config(){
self.tintColor = .clear
self.backgroundColor = .black

self.setTitleTextAttributes([.font:UIFont.systemFont(ofSize: 12, weight: .semibold)], for: .normal)
self.setTitleTextAttributes([.font:UIFont.systemFont(ofSize: 12, weight: .semibold)], for: .selected)
self.setTitleTextAttributes([.foregroundColor:UIColor.gray], for: .normal)
self.setTitleTextAttributes([.foregroundColor:UIColor.white], for: .selected)

for leftState in [UIControl.State.normal,.selected] {
for rightState in [UIControl.State.normal,.selected] {
self.setDividerImage(UIImage.render(size: CGSize(width: 16, height: 24), {
UIColor.clear.setFill()
}), forLeftSegmentState: leftState, rightSegmentState: rightState, barMetrics: .default)
}
}

}

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
let segment = object as! UIView
let isSelected = (segment.value(forKey: "isSelected") as! Int) == 1

segment.layer.borderWidth = 1
segment.layer.borderColor = isSelected ? UIColor.white.cgColor : UIColor.gray.cgColor
segment.backgroundColor = isSelected ? UIColor.black: UIColor.darkGray
}

deinit {
for segment in subviews {
segment.removeObserver(self, forKeyPath: "isSelected")
}
}
}

fileprivate extension UIImage {
class func render(size: CGSize, _ draw: () -> Void) -> UIImage? {
UIGraphicsBeginImageContext(size)
defer { UIGraphicsEndImageContext() }

draw()

return UIGraphicsGetImageFromCurrentImageContext()?
.withRenderingMode(.alwaysTemplate)
}
}

和UISegmentedControl的调用别无二致,最后的效果如下

res

仅仅是一个简单的控件,没有考虑到后续insertSegment的情况,本文中也没有用条纹背景,用灰色背景替代,有兴趣的小伙伴可以自己去实现完善.

0%