[iOS] 커스텀 슬라이더 만들기

숑이·2023년 10월 20일
0

iOS

목록 보기
23/26
post-custom-banner

iOS 개발 오픈 채팅방에서 1단위 씩 뚝뚝 끊기는 슬라이더를 어떻게 커스텀해서 구현해야하는지 모르겠다고 하셔서 직접 만들어봤습니다.

사실 단순히 1단위씩 뚝뚝 끊겨서 값이 설정되는 슬라이더는 기본적으로 UIKit에서 제공하는 UISlider로 구현할 수 있겠지만, 그것 뿐만 아니라 1단위마다 구분선(?) 이 필요했기 때문에 아예 커스텀 뷰를 만드는 것이 좋을 것 같다고 판단했습니다.

결과물

기능적 요구 사항

  1. 좌/우 슬라이드 기능
  2. 값 변화시 한 단위씩 뚝뚝 끊기는 효과
  3. 값 변화시 외부에서 관찰 가능

UI 요구 사항

위 커스텀 슬라이더를 만들기 위해서 아래와 같이 4개의 View들이 필요합니다

  1. trackView
  2. divider
  3. fillTrackView
  4. thumbView

코드 구현

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 패턴을 사용했습니다.

전체 코드

https://github.com/EJLee1209/UIKit_Storage/blob/Develop/iOS_Study/View/CustomView/CustomView/SliderView.swift

profile
iOS앱 개발자가 될테야
post-custom-banner

0개의 댓글