iOS 개발 오픈 채팅방에서 1단위 씩 뚝뚝 끊기는 슬라이더를 어떻게 커스텀해서 구현해야하는지 모르겠다고 하셔서 직접 만들어봤습니다.
사실 단순히 1단위씩 뚝뚝 끊겨서 값이 설정되는 슬라이더는 기본적으로 UIKit에서 제공하는 UISlider로 구현할 수 있겠지만, 그것 뿐만 아니라 1단위마다 구분선(?) 이 필요했기 때문에 아예 커스텀 뷰를 만드는 것이 좋을 것 같다고 판단했습니다.
위 커스텀 슬라이더를 만들기 위해서 아래와 같이 4개의 View들이 필요합니다
final class SliderView: UIView { ... }
먼저 커스텀뷰를 만들기 위해서 UIView를 상속하는 SliderView 클래스를 생성합니다.
//MARK: - SliderView.swift
private let trackView: UIView = {
let view = UIView()
view.backgroundColor = .systemGray5
return view
}()
private lazy var thumbView: UIView = {
let view = UIView()
view.backgroundColor = .systemBackground
view.isUserInteractionEnabled = true
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
view.addGestureRecognizer(gesture)
view.layer.shadowColor = UIColor.gray.cgColor
view.layer.shadowOffset = .init(width: 3, height: 3)
view.layer.shadowRadius = 8
view.layer.shadowOpacity = 0.8
return view
}()
private let fillTrackView: UIView = {
let view = UIView()
view.backgroundColor = .systemBlue
return view
}()
private var dividers: [UIView] = []
private var maxValue: Int
private var touchBeganPosX: CGFloat?
private var didLayoutSubViews: Bool = false
private let thumbSize: CGFloat = 30
private let dividerWidth: CGFloat = 8
필요한 프로퍼티를 정의합니다.
UIPanGestureRecognizer는 슬라이드 기능을 구현하기 위한 GestureRecognizer입니다.
handlePan 메서드는 아래에서 구현해보겠습니다.
//MARK: - LifeCycle
init(maxValue: Int) {
if maxValue < 1 {
self.maxValue = 1
}
else if maxValue > 20 {
self.maxValue = 20
}
else{
self.maxValue = maxValue
}
super.init(frame: .zero)
layout()
}
생성자는 인자로 정수형 타입의 maxValue를 전달받고, layout() 메서드를 호출합니다.
maxValue는 슬라이더의 최댓값이고, layout() 메서드는 AutoLayout을 설정하는 메서드입니다.
AutoLayout 부분은 생략하겠습니다.
private func makeDividerAndLayout() {
let unitWidth = trackView.frame.width / CGFloat(maxValue - 1)
for i in 0..<maxValue {
let dividerPosX = unitWidth * CGFloat(i)
let divider = makeDivider()
trackView.addSubview(divider)
divider.snp.makeConstraints { make in
make.centerY.equalTo(trackView)
make.left.equalTo(trackView).offset(dividerPosX - 4)
make.width.equalTo(dividerWidth)
make.height.equalTo(trackView).offset(7)
}
}
didLayoutSubViews.toggle()
}
private func makeDivider() -> UIView {
let divider = UIView()
divider.backgroundColor = .systemGray5
divider.clipsToBounds = true
divider.layer.cornerRadius = 3
dividers.append(divider)
return divider
}
makeDividerAndLayout() 메서드는 트랙의 구분선을 만들고, 배치하는 메서드입니다.
unitWidth는 한 단위의 너비입니다. 이 값으로 트랙 위에서의 구분선의 위치를 지정할 수 있습니다.
생성자로 전달받은 maxValue개의 구분선을 만들어서 트랙 위에 배치해줍니다.
makeDividerAndLayout() 메서드에서는 trackView의 frame을 사용하기 때문에 trackView의 크기가 정해진 후에 호출되어야 합니다.
override func layoutSubviews() {
super.layoutSubviews()
if !didLayoutSubViews {
makeDividerAndLayout()
thumbView.layer.cornerRadius = thumbView.frame.width / 2
thumbView.layer.shadowPath = UIBezierPath(
roundedRect: thumbView.bounds,
cornerRadius: thumbView.layer.cornerRadius
).cgPath
}
}
그래서 위와 같이 layoutSubviews() 를 오버라이딩하고, 그 안에서 makeDividerAndLayout() 를 호출합니다.
layoutSubviews() 메서드는 뷰의 크기나 위치가 변경될 때마다 호출되는 메서드입니다. trackView의 크기가 정해지면, 이 메서드가 호출될 것이기 때문에 이 메서드 안에서는 뷰의 최신 크기와 위치 정보를 사용할 수 있습니다.
뷰의 크기나 위치가 변경될 때마다 호출되기 때문에 makeDividerAndLayout() 메서드를 중복 호출하는 문제가 발생할 수 있습니다. 따라서 조건문을 통해 최초에 딱 1번만 호출하도록 코드를 작성했습니다.
//MARK: - Actions
@objc func handlePan(_ recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: thumbView)
if recognizer.state == .began {
// 팬 제스쳐가 시작된 x좌표 저장
touchBeganPosX = thumbView.frame.minX
}
if recognizer.state == .changed {
guard let startX = self.touchBeganPosX else { return }
var offSet = startX + translation.x // 시작지점 + 제스쳐 거리 = 현재 제스쳐 좌표
if offSet < 0 || offSet > trackView.frame.width { return } // 제스쳐가 trackView의 범위를 벗어나는 경우 무시
let unitWidth = trackView.frame.width / CGFloat(maxValue - 1) // 1단위 너비
// value = 반올림(현재 제스쳐 좌표 / 1단위의 크기) -> 슬라이더의 값이 변할 때마다 똑똑 끊기는 효과를 주기 위해
let newValue = round(offSet / unitWidth)
offSet = unitWidth * newValue - (thumbSize / 2)
thumbView.snp.updateConstraints { make in
make.left.equalTo(trackView).offset(offSet)
}
fillTrackView.snp.updateConstraints { make in
make.width.equalTo(offSet)
}
if value != Int(newValue + 1) {
value = Int(newValue + 1)
for i in 0..<value {
dividers[i].backgroundColor = .systemBlue
}
for i in value..<maxValue {
dividers[i].backgroundColor = .systemGray5
}
}
}
}
슬라이드 기능을 구현하는 메서드입니다.
제스쳐에 따라 thumbView와 fillTrackView를 재배치합니다.
자세한 설명은 주석으로 달아놨습니다.
protocol SliderViewDelegate: AnyObject {
func sliderView(_ sender: SliderView, changedValue value: Int)
}
weak var delegate: SliderViewDelegate?
var value: Int = 1 {
didSet {
delegate?.sliderView(self, changedValue: value)
}
}
마지막으로 값의 변화를 외부에서 관찰할 수 있도록 만들기 위해서 Delegate 패턴을 사용했습니다.