맛동산이

CustomView) Two Button Slider 구현하기 UIkit 본문

앱/Swift

CustomView) Two Button Slider 구현하기 UIkit

진ddang 2024. 10. 17. 19:38

Two button slider를 구한현 예제가 많지 않고, 거기에 이번에 회사 스팩중에 하나가 슬라이더 안에 밑에 격자가 들어가거나, 위에 점이 들어가거나 하는 커스텀이 많이 들어갔다.

그래서 나처럼 구현할 사람이 있을것 같아서 블로그에 남겨본다.

먼저 편의성을 위해서 snapkit과 then을 사용한 코드 임을 감안! 해주세요!

슬라이더 예시

 

 

 

//
//  SliderView.swift
//  twoHandledSlider
//
//  Created by 최진용 on 10/12/24.
//

import UIKit
import Then
import SnapKit

protocol SliderViewDelegate: AnyObject {
    func sliderView(_ sender: SliderView, changedValue value: Int)
}

final class SliderView: UIView {
    
    weak var delegate: SliderViewDelegate?
    var value: Int = 1 {
        didSet {
            delegate?.sliderView(self, changedValue: value)
        }
    }
    
    private let trackView = UIView().then {
        $0.backgroundColor = .systemGray5
    }
    
    private lazy var leftThumbView = UIView().then {
        $0.backgroundColor = .systemBackground
        $0.isUserInteractionEnabled = true
        let gesture = UIPanGestureRecognizer(target: self, action: #selector(handleLeftPan))
        $0.addGestureRecognizer(gesture)
        $0.layer.shadowColor = UIColor.gray.cgColor
        $0.layer.shadowOffset = .init(width: 3, height: 3)
        $0.layer.shadowRadius = 8
        $0.layer.shadowOpacity = 0.8
    }
    
    private lazy var rightThumbView = UIView().then {
        $0.backgroundColor = .systemBackground
        $0.isUserInteractionEnabled = true
        let gesture = UIPanGestureRecognizer(target: self, action: #selector(handleRightPan))
        $0.addGestureRecognizer(gesture)
        $0.layer.shadowColor = UIColor.gray.cgColor
        $0.layer.shadowOffset = .init(width: 3, height: 3)
        $0.layer.shadowRadius = 8
        $0.layer.shadowOpacity = 0.8
    }
    
    private let fillTrackView = UIView().then {
        $0.backgroundColor = .systemBlue
    }
    
    private var dividers: [UIView] = []
    
    private var maxValue: Int
    private var touchLeftBeganPosX: CGFloat?
    private var touchRightBeganPosX: CGFloat?
    private var didLayoutSubViews: Bool = false
    
    private let thumbSize: CGFloat = 30
    
    init(maxValue: Int) {
        if maxValue < 1 {
            self.maxValue = 1
        }
        else if maxValue > 20 {
            self.maxValue = 20
        }
        else{
            self.maxValue = maxValue
        }
        super.init(frame: .zero)
        
        render()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        if !didLayoutSubViews {
            makeDividerAndLayout()
            leftThumbView.layer.cornerRadius = leftThumbView.frame.width / 2
            leftThumbView.layer.shadowPath = UIBezierPath(
                roundedRect: leftThumbView.bounds,
                cornerRadius: leftThumbView.layer.cornerRadius
            ).cgPath
            
            rightThumbView.layer.cornerRadius = rightThumbView.frame.width / 2
            rightThumbView.layer.shadowPath = UIBezierPath(
                roundedRect: rightThumbView.bounds,
                cornerRadius: rightThumbView.layer.cornerRadius
            ).cgPath
        }
    }
    
    private func render() {
        [trackView, fillTrackView, leftThumbView, rightThumbView].forEach(addSubview)
        
        trackView.snp.makeConstraints {
            $0.left.right.equalToSuperview().inset(10)
            $0.verticalEdges.equalToSuperview().inset(10)
        }
        leftThumbView.snp.makeConstraints {
            $0.centerY.equalTo(trackView)
            $0.left.equalTo(trackView).offset(-(thumbSize / 2))
            $0.size.equalTo(thumbSize)
        }
        rightThumbView.snp.makeConstraints {
            $0.centerY.equalTo(trackView)
            $0.right.equalTo(trackView).offset(thumbSize / 2)
            $0.size.equalTo(thumbSize)
        }
        fillTrackView.snp.makeConstraints {
            $0.left.equalTo(leftThumbView)
            $0.right.equalTo(rightThumbView)
            $0.top.bottom.equalTo(trackView)
        }
    }
    
    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 {
                $0.top.equalTo(trackView.snp.bottom).offset(6)
                $0.left.equalTo(trackView).offset(dividerPosX - 4)
                $0.width.equalTo(1)
                $0.height.equalTo(4)
            }
        }
        
        didLayoutSubViews.toggle()
    }
    
    private func makeDivider() -> UIView {
        let divider = UIView()
        divider.backgroundColor = .black
        divider.clipsToBounds = true
        dividers.append(divider)
        return divider
    }
    
    @objc 
    func handleLeftPan(_ recognizer: UIPanGestureRecognizer) {
        let translation = recognizer.translation(in: leftThumbView)
        
        if recognizer.state == .began {
            // 팬 제스쳐가 시작된 x좌표 저장
            touchLeftBeganPosX = leftThumbView.frame.minX
        }
        if recognizer.state == .changed {
            guard let startX = self.touchLeftBeganPosX 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)
            
            guard (rightThumbView.frame.minX - unitWidth) > offSet else { return }
            
            leftThumbView.snp.updateConstraints {
                $0.left.equalTo(trackView).offset(offSet)
            }
            fillTrackView.snp.updateConstraints {
                $0.left.equalTo(leftThumbView)
                $0.right.equalTo(rightThumbView)
            }
        }
    }

    @objc
    func handleRightPan(_ recognizer: UIPanGestureRecognizer) {
        let translation = recognizer.translation(in: rightThumbView)
        
        if recognizer.state == .began {
            // 팬 제스쳐가 시작된 x좌표 저장
            touchRightBeganPosX = rightThumbView.frame.maxX
        }
        if recognizer.state == .changed {
            guard let startX = self.touchRightBeganPosX else { return }
            print(startX)
            var offSet = startX + translation.x // 시작지점 + 제스쳐 거리 = 현재 제스쳐 좌표
            print(offSet)
            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)
            //오른쪽 기준(trackView.frame.width)에서 뺄 값
            offSet = trackView.frame.width - (unitWidth * newValue + (thumbSize / 2))
//            (unitWidth * newValue + (thumbSize / 2))
            guard (leftThumbView.frame.minX + unitWidth) < (unitWidth * newValue + (thumbSize / 2)) else { return }
            
            rightThumbView.snp.updateConstraints {
                $0.right.equalTo(trackView).offset(-offSet)
            }
            fillTrackView.snp.updateConstraints {
                $0.left.equalTo(leftThumbView)
                $0.right.equalTo(rightThumbView)
            }
        }
    }

}

 

 

여기에서 delegate부분은 직접 구현해주시며 됩니다!

반응형