관리 메뉴

김종권의 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을 서브클래싱하여 사용

ThumbButton
isSelected 상태

  • 동그란 원을 만들기 위해서 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를 스캐일화하고 전체 길이로 나누어 주는것

drag한 값을 구하는 원리

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값을 구하여 레이아웃을 업데이트

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

Comments