Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
Tags
- UICollectionView
- clean architecture
- RxCocoa
- 리팩토링
- swiftUI
- Refactoring
- Protocol
- Xcode
- 리펙토링
- HIG
- 리펙터링
- SWIFT
- Human interface guide
- rxswift
- 애니메이션
- combine
- UITextView
- collectionview
- ribs
- Clean Code
- 스위프트
- ios
- tableView
- Observable
- map
- swift documentation
- MVVM
- uitableview
- uiscrollview
- 클린 코드
Archives
- Today
- Total
김종권의 iOS 앱 개발 알아가기
[iOS - swift] Custom Slider (커스텀 슬라이더), point, beginTracking, continueTracking, endTracking 본문
UI 컴포넌트 (swift)
[iOS - swift] Custom Slider (커스텀 슬라이더), point, beginTracking, continueTracking, endTracking
jake-kim 2022. 6. 3. 22:06
예제에 사용한 프레임워크
- 코드로 레이아웃 정의를 편하게 하기 위해서 SnapKit 사용
구현 아이디어
- point, beginTracking, continueTracking, endTracking을 통해서 터치 이벤트 획득
- superview에서 위 4개의 메소드를 사용하기 위해서, subview들의 제스쳐를 비활성화 (isUserInteractionEnabled = false)
- 커스텀 뷰에서 value가 바뀔때마다, valueChanged 메소드로 알려주어야 하기때문에 UIControl를 서브클래싱
- frame을 알아서, autolayout으로 update 시켜주면 완성
사전 지식 1) point(inside:with:) 메소드
- 해당 메소드로 터치 이벤트를 막을지, 실행할지 결정이 가능
- frame.contains()를 통해서 특정 뷰를 탭한 경우만 터치 이벤트가 유효하도록 정의
// JKSlider.swift
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
super.point(inside: point, with: event)
return self.lowerThumbButton.frame.contains(point) || self.upperThumbButton.frame.contains(point)
}
사전 지식 2) beginTracking, continueTracking, endTracking
- beginTracking
- 위 point메소드에서 true를 반환받은 터치 이벤트가 시작될때 불리는 메소드
- 터치가 일어나기 시작할때 터치의 위치를 알기 위해서 사용, point와 같이 false나 true를 반환하여 터치 이벤트를 계속할지 결정
- continueTracking
- beginTracking에서 true를 받환받은 터치 이벤트가 불리는 메소드 (드래그 시 계속 호출)
- 이 메소드에서 오토레이아웃을 통해 계속 뷰의 위치를 업데이트하여 드래그가 되도록 구현
- endTracking
- continueTracking에서 true를 반환받은 터치 이벤트가 불리는 메소드 (터치를 마침내 뗀 경우)
- 해당 메소드에서 isSelected = false와 같은 코드 호출
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
super.beginTracking(touch, with: event)
print(touch.location(in: self))
return true
}
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
super.continueTracking(touch, with: event)
print(touch.location(in: self))
return true
}
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
super.endTracking(touch, with: event)
print(touch?.location(in: self))
}
사전 지식 3) clamped 처리
- bound 안에서의 값을 구할때 사용
- value.clamped(to: (1...100))을 하면 value값이 1보다 작으면 1, 100보다 크면 100으로 적용되게 하는 extension 코드
private extension Comparable {
func clamped(to limits: ClosedRange<Self>) -> Self {
min(max(self, limits.lowerBound), limits.upperBound)
}
}
SnapKit에서의 Constraint 업데이트 처리
- SnapKit을 사용하면 아래 코드처럼, constraint를 지정하는 동시에 constraint 인스턴스를 가져와서 해당 인스턴스만 쉽게 업뎃이 가능
private var leftConstraint: Constraint?
self.lowerThumbButton.snp.makeConstraints {
$0.top.bottom.equalToSuperview()
$0.right.lessThanOrEqualTo(self.upperThumbButton.snp.left)
$0.left.greaterThanOrEqualToSuperview()
$0.width.equalTo(self.snp.height)
self.leftConstraint = $0.left.equalTo(self.snp.left).priority(999).constraint // .constraint로 값 가져오기 테크닉
}
self.leftConstraint?.update(offset: offset)
필요한 UI와 Extension 준비
- 동그란 뷰인,ThumbButton
- UIButton은 기본적으로 isSelected 상태가 존재하므로, UIButton을 서브클래싱하여 사용
- 동그란 원을 만들기 위해서 height의 반 값으로 cornerRadius를 지정하는 RoundableButton 준비
- 구체적인 내용은 이전 포스팅 글 참고
class RoundableButton: UIButton {
override func layoutSubviews() {
super.layoutSubviews()
self.layer.cornerRadius = self.frame.height / 2
}
}
- RoundableButton을 서브클래싱하는 ThumbButton 준비
- isSelected 상태 색상을 정의하고, 그림자와 윤곽선이 보이게 구현
class ThumbButton: RoundableButton {
override var isSelected: Bool {
didSet {
self.backgroundColor = self.isSelected ? .lightGray : .white
}
}
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = .white
self.layer.shadowOffset = CGSize(width: 0, height: 3)
self.layer.shadowColor = UIColor.black.cgColor
self.layer.shadowOpacity = 0.3
self.layer.borderWidth = 1.0
self.layer.borderColor = UIColor.gray.withAlphaComponent(0.1).cgColor
}
required init?(coder: NSCoder) {
fatalError()
}
}
커스텀 슬라이더 구현
- 클래스 준비
- 사용하는쪽에서 valueChanged 이벤트를 수신해야하므로, UIControl을 서브클래싱하여 구현
final class JKSlider: UIControl {
// MARK: Constant
private enum Constant {
static let barRatio = 1.0/10.0
}
}
- UI 구현
- 모두 JKSlider의 터치 이벤트로 받아야하므로, 나머지 isUserInteractionEnabled를 false로 입력
// MARK: UI
private let lowerThumbButton: ThumbButton = {
let button = ThumbButton()
button.isUserInteractionEnabled = false
return button
}()
private let upperThumbButton: ThumbButton = {
let button = ThumbButton()
button.isUserInteractionEnabled = false
return button
}()
private let trackView: UIView = {
let view = UIView()
view.backgroundColor = .gray
view.isUserInteractionEnabled = false
return view
}()
private let trackTintView: UIView = {
let view = UIView()
view.backgroundColor = .green
view.isUserInteractionEnabled = false
return view
}()
- 외부에서 접근 가능한 프로퍼티 정의
- updateLayer 메소드는 밑에서 계속
// MARK: Properties
var minValue = 0.0 {
didSet { self.lower = self.minValue }
}
var maxValue = 10.0 {
didSet { self.upper = self.maxValue }
}
var lower = 0.0 {
didSet { self.updateLayout(self.lower, true) }
}
var upper = 0.0 {
didSet { self.updateLayout(self.upper, false) }
}
var lowerThumbColor = UIColor.white {
didSet { self.lowerThumbButton.backgroundColor = self.lowerThumbColor }
}
var upperThumbColor = UIColor.white {
didSet { self.upperThumbButton.backgroundColor = self.upperThumbColor }
}
var trackColor = UIColor.gray {
didSet { self.trackView.backgroundColor = self.trackColor }
}
var trackTintColor = UIColor.green {
didSet { self.trackTintView.backgroundColor = self.trackTintColor }
}
- 내부적인 계산에 사용하는 프로퍼티 정의
private var previousTouchPoint = CGPoint.zero
private var isLowerThumbViewTouched = false
private var isUpperThumbViewTouched = false
private var leftConstraint: Constraint?
private var rightConstraint: Constraint?
private var thumbViewLength: Double {
Double(self.bounds.height)
}
- 레이아웃 정의
// MARK: Init
required init?(coder: NSCoder) {
fatalError("xib is not implemented")
}
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(self.trackView)
self.addSubview(self.trackTintView)
self.addSubview(self.lowerThumbButton)
self.addSubview(self.upperThumbButton)
self.lowerThumbButton.snp.makeConstraints {
$0.top.bottom.equalToSuperview()
$0.right.lessThanOrEqualTo(self.upperThumbButton.snp.left)
$0.left.greaterThanOrEqualToSuperview()
$0.width.equalTo(self.snp.height)
self.leftConstraint = $0.left.equalTo(self.snp.left).priority(999).constraint // .constraint로 값 가져오기 테크닉
}
self.upperThumbButton.snp.makeConstraints {
$0.top.bottom.equalToSuperview()
$0.left.greaterThanOrEqualTo(self.lowerThumbButton.snp.right)
$0.right.lessThanOrEqualToSuperview()
$0.width.equalTo(self.snp.height)
self.rightConstraint = $0.left.equalTo(self.snp.left).priority(999).constraint
}
self.trackView.snp.makeConstraints {
$0.left.right.centerY.equalToSuperview()
$0.height.equalTo(self).multipliedBy(Constant.barRatio)
}
self.trackTintView.snp.makeConstraints {
$0.left.equalTo(self.lowerThumbButton.snp.right)
$0.right.equalTo(self.upperThumbButton.snp.left)
$0.top.bottom.equalTo(self.trackView)
}
}
- 터치 이벤트 처리1
- 동그란 버튼을 터치했는지 확인
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
super.point(inside: point, with: event)
return self.lowerThumbButton.frame.contains(point) || self.upperThumbButton.frame.contains(point)
}
- 터치 이벤트 처리 2
- 위에서 처리해준 작업과 동일한 동그란 버튼을 터치했는지 체크하는 코드와, 어떤 버튼을 터치했는지, isSelected 처리
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
super.beginTracking(touch, with: event)
self.previousTouchPoint = touch.location(in: self)
self.isLowerThumbViewTouched = self.lowerThumbButton.frame.contains(self.previousTouchPoint)
self.isUpperThumbViewTouched = self.upperThumbButton.frame.contains(self.previousTouchPoint)
if self.isLowerThumbViewTouched {
self.lowerThumbButton.isSelected = true
} else {
self.upperThumbButton.isSelected = true
}
return self.isLowerThumbViewTouched || self.isUpperThumbViewTouched
}
- 터치 이벤트 처리 3
- 터치된 지점을 파악하고, 터치 지점으로부터 드래그한 만큼 값을 사용하여 value를 계산
- self.lower, self.upper에 값이 입력되면 각 didSet에서 autolayout을 통해 레이아웃을 업데이트
- drag한 지점, 스캐일된 (50~1000) 값을 구하는 원리 - b를 알고 있으므로, b를 스캐일화하고 전체 길이로 나누어 주는것
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
super.continueTracking(touch, with: event)
let touchPoint = touch.location(in: self)
defer {
self.previousTouchPoint = touchPoint
self.sendActions(for: .valueChanged)
}
let drag = Double(touchPoint.x - self.previousTouchPoint.x)
let scale = self.maxValue - self.minValue
let scaledDrag = scale * drag / Double(self.bounds.width - self.thumbViewLength) // thumbView가 움직일수 있는 영역으로 나누어주기
if self.isLowerThumbViewTouched {
self.lower = (self.lower + scaledDrag)
.clamped(to: (self.minValue...self.upper))
} else {
self.upper = (self.upper + scaledDrag)
.clamped(to: (self.lower...self.maxValue))
}
return true
}
- 터치 이벤트 처리 4
- isSelected를 false 처리
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
super.endTracking(touch, with: event)
self.sendActions(for: .valueChanged)
self.lowerThumbButton.isSelected = false
self.upperThumbButton.isSelected = false
}
- 레이아웃 업데이트 처리
- self.lower, self.upper 프로퍼티의 didSet에서 해당 메소드를 호출
- offset값을 구하여 레이아웃을 업데이트
private func updateLayout(_ value: Double, _ isLowerThumb: Bool) {
DispatchQueue.main.async {
let length = self.bounds.width - self.thumbViewLength
let startValue = value - self.minValue
let offset = length * startValue / (self.maxValue - self.minValue)
if isLowerThumb {
self.leftConstraint?.update(offset: offset)
} else {
self.rightConstraint?.update(offset: offset)
}
}
}
사용하는 쪽
import UIKit
import SnapKit
class ViewController: UIViewController {
private let slider: JKSlider = {
let slider = JKSlider()
slider.minValue = 1
slider.maxValue = 100
slider.lower = 1
slider.upper = 75
slider.addTarget(self, action: #selector(changeValue), for: .valueChanged)
return slider
}()
private let label: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 20)
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(self.slider)
self.view.addSubview(self.label)
self.slider.snp.makeConstraints {
$0.height.equalTo(40)
$0.width.equalTo(300)
$0.center.equalToSuperview()
}
self.label.snp.makeConstraints {
$0.top.equalToSuperview().inset(80)
$0.centerX.equalToSuperview()
}
}
@objc private func changeValue() {
self.label.text = "\(Int(self.slider.lower)) ~ \(Int(self.slider.upper))"
}
}
* 전체 코드: https://github.com/JK0369/ExSlider
'UI 컴포넌트 (swift)' 카테고리의 다른 글
Comments