Hướng dẫn cách làm slider hai chiều trong Swift

by DaoNM2
24 views

Là một iOS developer, chắc hẳn mọi người đã từng làm những tính năng mà các UIKit của Apple không thể đáp ứng được yêu cầu. Lúc này chúng ta cần phải thực hiện custom hoặc tạo một common UI với cơ chế mới thoả mãn yêu cầu của khách hàng. Và ở dự án mình cũng có những yêu cầu như vậy, nên bài viết này mình sẽ hướng dẫn các bạn làm slider hai chiều trong Swift.

Mục đích của chúng ta là đạt được kết quả như sau:

Slider hai chiều

I. Yêu cầu

Cần tạo một common slider thoả mãn các điều kiện như sau:

  1. Slider được chia thành các đoạn bằng nhau có thể chỉnh sửa, Slider có 2 điểm kéo, có thể kéo được từ giá trị min tới giá trị max, kéo chồng lên nhau, khi kéo tới các điểm sẽ thực hiện rung.
  2. Cho phép thay đổi giao diện

II. Giải pháp

  1. Tạo một mảng string để lưu các điểm cho phép và dùng nó để tính toán và hiển thị dữ liệu cho Slider hai chiều
  2. Dựa vào số phần tử trong mảng để tính khoảng cách giữa các điểm và vị trí của nó.
  3. Đối với tính năng kéo mình sẽ dùng UIPanGestureRecognizer để xử lí. Khi kết thúc hành động kéo thì xác định vị trí của điểm được kéo so với các điểm trong mảng, nếu gần điểm nào thì tự di chuyển tới vị trí của điểm đó.

III. Hướng dẫn chi tiết

Để cho tất cả mọi người đều có thể thực hiện một các dễ dàng thì mình sẽ hướng dẫn chi tiết theo các bước như sau:

1. Tạo mới file swift

Chúng ta cần tạo mới file swift để làm common Slider hai chiều theo các bước sau:

Trong project, bấm chuột phải vào thư mục muốn tạo file -> new file -> chọn swift -> đặt tên cho file, ở đây mình đặt là: TWSlider.swift

Kết quả tạo file

2. Thêm nội dung và logic cho file TWSlider.swift

Trước tiên chúng ta cần thêm image vào asset của project để sử dụng cho slider, các bạn cũng có thể thêm các image tương ứng với yêu cầu của dự án đang làm.

Và chúng ta cũng cần thêm Then vào để setup UI cho tiện như sau

public protocol Then {}

public extension Then {
    @discardableResult
    func then(_ block: (Self) -> Void) -> Self {
        block(self)
        return self
    }
}

extension NSObject: Then {}
extension CGPoint: Then {}
extension CGRect: Then {}
extension CGSize: Then {}
extension CGPath: Then {}

1. Tạo protocol để bắn dữ liệu cần thiết ra màn hình cần xử lí logic

protocol TWSliderDelegate: AnyObject {
    func sliderScrolled(_ slider: TWSlider?,
                        toMinIndex minIndex: Int,
                        andMaxIndex maxIndex: Int,
                        endDragDrop: Bool)
}

2. Tạo các biến cho phép thay đổi các giá trị của slider

class TWSlider: UIView {
    
    // PUBLIC variable
    // The number of points in slider
    var numberOfSegments: Int = 0
    
    // This value should be set if slider button should overlap or not. Default to NO. ie., 1 segment space will be present between the sliders.
    var shouldSliderButtonOverlap: Bool = false
    
    // The color that is used for rangeSlider unselected range view. (i.e., the view that is not within the slider points). Default is #DFDEE4.
    var rangeSliderBackgroundColor: UIColor = .gray
    
    // The color that is used for rangeSlider selected range view. (i.e., the view that is between the slider points). Default is #FF9800.
    var rangeSliderForegroundColor: UIColor = .orange
    
    // The label color to be used for min range display. Default is white.
    var rangeDisplayLabelColor: UIColor = .white
    
    // The label color to be used for min range display. Default is black.
    var minMaxDisplayLabelColor: UIColor = .black
    
    // The color for segment button when it is within the selected range. Default is clear.
    var segmentSelectedColor: UIColor = .clear
    
    // The color for segment button when it is outside the selected range. Default is #F8F8FA.
    var segmentUnSelectedColor: UIColor = .white
    
    // The image used for displaying slider buttons. By default "ic_sliderButton". rangeSliderButtonColor will be used if not set
    var rangeSliderButtonImage: UIImage?
    
    // The image for segment button when it is within the selected range. If not set, segmentSelectedColor will be used.
    var segmentSelectedImage: UIImage?
    
    // The image for segment button when it is outside the selected range. If not set, segmentUnSelectedColor will be used.
    var segmentUnSelectedImage: UIImage?
    
    // The size of the slider button. If not set, defaults to (16, 16).
    var sliderSize: CGSize = CGSize(width: 16, height: 16)
    
    // The size of the segments. If not set, defaults to (4, 4).
    var segmentSize: CGSize = CGSize(width: 4, height: 4)
    
    // The min and max range label text to be set by caller
    var minRangeText: String?
    var maxRangeText: String?
    
    // The delegate property
    weak var delegate: TWSliderDelegate?
    
    // PRIVATE VAR
    // Slider button size
    private var SLIDER_BUTTON_WIDTH: CGFloat = 44.0
    
    // Slider frame
    private var DEFAULT_SLIDER_FRAME: CGRect!
    
    // Segment width
    private var segmentWidth: CGFloat!
    
    // The backgroundView represent unselected/outside range view
    private var sliderBackgroundView: UIView!
    
    // The foregroundView represent selected/inside range view
    private var sliderForegroundView: UIView!
    
    // The label placed below the min and max sliders
    private var minRangeView: UIView!
    private var maxRangeView: UIView!
    private var minRangeLabel: UILabel!
    private var maxRangeLabel: UILabel!
    
    // min and max value
    private var minLabel: UILabel!
    private var maxLabel: UILabel!
    
    // Represent the range slider on either side of the slider
    private var startSliderButton: UIButton!
    private var endSliderButton: UIButton!
    
    // The segment index or percent for initial slider position loading for segmented and unsegmented respectively
    private var minRangeInitialIndex: Int?
    private var maxRangeInitialIndex: Int?
    
    // Padding range view with slider button
    private let iconRangeMidView = "ic_rangeMidView"
    private let iconRangeView = "ic_rangeView"
    private let iconSliderButton = "ic_sliderButton"
    private let iconSegmentUnSelected = "ic_segmentUnSelected"
    private let paddingRangeView: CGFloat = 4
    
    private var sliderMidView: UIView!
    private var sliderMidLabel: UILabel!
    private var checkInit: Bool = false
    // MARK: init
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setUpUI()
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setUpUI()
    }
    
    func setUpUI() {
        setDefaultValues()
        initSliderViews()
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        updateFrame()
    }
}

3. Tạo các hàm private để setup UI cho slider

extension TWSlider {
    
    // Default Initializaer
    private func setDefaultValues() {
        checkInit = false
        numberOfSegments = 2
        shouldSliderButtonOverlap = false
        
        minRangeLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 24, height: 24))
        minRangeLabel.then {
            $0.textColor = rangeDisplayLabelColor
            $0.textAlignment = .center
            $0.font = UIFont.systemFont(ofSize: 12, weight: .medium)
        }
        
        minRangeView = UIView(frame: CGRect(x: 0, y: 0, width: 24, height: 29))
        minRangeView.then {
            $0.center = CGPoint(x: SLIDER_BUTTON_WIDTH / 2, y: bounds.midY - (SLIDER_BUTTON_WIDTH / 2) - (paddingRangeView / 2))
            if let image = UIImage(named: iconRangeView) {
                $0.backgroundColor = UIColor(patternImage: image)
            }
            addSubview($0)
            $0.addSubview(minRangeLabel)
        }
        
        maxRangeLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 24, height: 24))
        maxRangeLabel.then {
            $0.textColor = rangeDisplayLabelColor
            $0.textAlignment = .center
            $0.font = UIFont.systemFont(ofSize: 12, weight: .medium)
        }
        
        maxRangeView = UIView(frame: CGRect(x: 0, y: 0, width: 24, height: 29))
        maxRangeView.then {
            $0.center = CGPoint(x: SLIDER_BUTTON_WIDTH / 2, y: bounds.midY + (SLIDER_BUTTON_WIDTH / 2) + (paddingRangeView / 2))
            if let image = UIImage(named: iconRangeView) {
                $0.backgroundColor = UIColor(patternImage: image)
            }
            addSubview($0)
            $0.addSubview(maxRangeLabel)
        }
        
        minLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 24, height: 24))
        minLabel.then {
            $0.center = CGPoint(x: SLIDER_BUTTON_WIDTH / 2, y: bounds.midY + (SLIDER_BUTTON_WIDTH / 2))
            $0.textColor = minMaxDisplayLabelColor
            $0.textAlignment = .center
            $0.font = UIFont.systemFont(ofSize: 12, weight: .regular)
            addSubview($0)
        }
        
        maxLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 24, height: 24))
        maxLabel.then {
            $0.center = CGPoint(x: bounds.maxX - SLIDER_BUTTON_WIDTH, y: bounds.midY + (SLIDER_BUTTON_WIDTH / 2))
            $0.textColor = minMaxDisplayLabelColor
            $0.textAlignment = .center
            $0.font = UIFont.systemFont(ofSize: 12, weight: .regular)
            addSubview($0)
        }
        
        // Mid View
        sliderMidLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 49, height: 24))
        sliderMidLabel.then {
            $0.textColor = rangeDisplayLabelColor
            $0.textAlignment = .center
            $0.font = UIFont.systemFont(ofSize: 12, weight: .medium)
        }
        sliderMidView = UIView(frame: CGRect(x: 0, y: 0, width: 49, height: 29))
        sliderMidView.then {
            $0.center = CGPoint(x: SLIDER_BUTTON_WIDTH / 2, y: bounds.midY + (SLIDER_BUTTON_WIDTH / 2) + (paddingRangeView / 2))
            if let image = UIImage(named: iconRangeMidView) {
                $0.backgroundColor = UIColor(patternImage: image)
            }
            addSubview($0)
            $0.addSubview(sliderMidLabel)
            $0.alpha = 0
        }
        segmentWidth = getSegmentWidth(forSegmentCount: numberOfSegments)
        rangeSliderButtonImage = UIImage(named: iconSliderButton)
        segmentUnSelectedImage = UIImage(named: iconSegmentUnSelected)
        
    }
    
    // Init the sliding representing views
    private func initSliderViews() {
        DEFAULT_SLIDER_FRAME = CGRect(x: SLIDER_BUTTON_WIDTH / 2, y: self.bounds.midY, width: self.bounds.width - SLIDER_BUTTON_WIDTH, height: 4)
        
        sliderBackgroundView = UIView(frame: DEFAULT_SLIDER_FRAME)
        sliderBackgroundView.then {
            $0.backgroundColor = rangeSliderBackgroundColor
            addSubview($0)
        }
        
        sliderForegroundView = UIView(frame: DEFAULT_SLIDER_FRAME)
        sliderForegroundView.then {
            $0.backgroundColor = rangeSliderForegroundColor
            addSubview($0)
        }
        startSliderButton = getSegmentButton(withSegmentIndex: 1, isSlider: true)
        addSubview(startSliderButton)
        
        endSliderButton = getSegmentButton(withSegmentIndex: numberOfSegments, isSlider: true)
        addSubview(endSliderButton)
        
        // Pan gesture for identifying the sliding (More accurate than touchesMoved).
        addPanGestureRecognizer()
    }
    
    // Update the frames
    private func updateFrame() {
        if self.numberOfSegments >= 2 {
            // If the range selectors are at the extreme points, then reset the frame. Else fo nothing
            if isRangeSlidersPlacedAtExtremePosition() {
                DEFAULT_SLIDER_FRAME = CGRect(x: SLIDER_BUTTON_WIDTH / 2, y: self.bounds.midY, width: self.bounds.width - SLIDER_BUTTON_WIDTH, height: 4)
                self.sliderBackgroundView.frame = DEFAULT_SLIDER_FRAME
                self.sliderForegroundView.frame = DEFAULT_SLIDER_FRAME
                
                self.sliderBackgroundView.backgroundColor = self.rangeSliderBackgroundColor
                self.sliderForegroundView.backgroundColor = self.rangeSliderForegroundColor
                
                segmentWidth = getSegmentWidth(forSegmentCount: numberOfSegments)
                
                startSliderButton.center = CGPoint(x: SLIDER_BUTTON_WIDTH / 2, y: sliderBackgroundView.frame.midY)
                endSliderButton.center = getSegmentCenterPoint(forSegmentIndex: numberOfSegments)
                
                minRangeView.center = CGPoint(x: startSliderButton.frame.midX, y: bounds.midY - (SLIDER_BUTTON_WIDTH / 2) - (paddingRangeView / 2))
                maxRangeView.center = CGPoint(x: endSliderButton.frame.midX, y: bounds.midY - (SLIDER_BUTTON_WIDTH / 2) - (paddingRangeView / 2))
                
                minLabel.center = CGPoint(x: startSliderButton.frame.midX, y: bounds.midY + (SLIDER_BUTTON_WIDTH / 2))
                maxLabel.center = CGPoint(x: endSliderButton.frame.midX, y: bounds.midY + (SLIDER_BUTTON_WIDTH / 2))
                
                sliderMidView.center = CGPoint(x: startSliderButton.frame.midX, y: bounds.midY - (SLIDER_BUTTON_WIDTH / 2) - (paddingRangeView / 2))
                
                setImageForSegmentOrSliderButton(startSliderButton, isSlider: true)
                setImageForSegmentOrSliderButton(endSliderButton, isSlider: true)
                
                // Reset the frame of all the intermediate buttons
                for segmentIndex in 1...numberOfSegments {
                    let segmentButton = self.viewWithTag(segmentIndex) as? UIButton
                    segmentButton?.center = getSegmentCenterPoint(forSegmentIndex: segmentIndex)
                    if let button = segmentButton {
                        setImageForSegmentOrSliderButton(button, isSlider: false)
                    }
                }
                
                // Slide the buttons if the initial position is needed
                slideRangeSliderButtonsIfNeeded()
                checkInit = true
            }
        }
    }
}

4. Tạo các hàm để tính toán vị trí và di chuyển slider

extension TWSlider {
    private func isRangeSlidersPlacedAtExtremePosition() -> Bool {
        let sliderBackgroundViewMaxX = sliderBackgroundView.frame.maxX + (SLIDER_BUTTON_WIDTH / 2)
        return (startSliderButton.frame.minX == 0.0 && endSliderButton.frame.maxX == sliderBackgroundViewMaxX)
    }
    
    private func slideRangeSliderButtonsIfNeeded() {
        var startScrollPoint = CGPoint.zero
        var endScrollPoint = CGPoint(x: bounds.size.width, y: 0)
        if let min = minRangeInitialIndex, let max = maxRangeInitialIndex, min < max {
            let startX = getSegmentCenterPoint(forSegmentIndex: min).x
            startScrollPoint.x = startX > (SLIDER_BUTTON_WIDTH / 2) ? startX : SLIDER_BUTTON_WIDTH / 2
            let endX = getSegmentCenterPoint(forSegmentIndex: max).x
            endScrollPoint.x = endX > (SLIDER_BUTTON_WIDTH / 2) ? endX : SLIDER_BUTTON_WIDTH / 2
        }
        scrollStartAndEndSlider(for: startScrollPoint, andEndScroll: endScrollPoint)
        minRangeInitialIndex = 0
        maxRangeInitialIndex = 0
    }
    
    private func scrollStartAndEndSlider(for startScrollPoint: CGPoint, andEndScroll endScrollPoint: CGPoint) {
        startSliderButton.isSelected = true
        sliderDidSlide(for: startScrollPoint)
        startSliderButton.isSelected = false
        
        endSliderButton.isSelected = true
        sliderDidSlide(for: endScrollPoint)
        endSliderButton.isSelected = false
    }
}

5. Tạo các hàm tính toán frame của slider

extension TWSlider {
    private func getSliderViewWidth() -> CGFloat {
        let startMinX = startSliderButton.frame.midX
        let endMinX = endSliderButton.frame.midX
        if startMinX > endMinX {
            return startMinX - endMinX
        } else {
            return endMinX - startMinX
        }
    }
    
    private func sliderMidPoint(forPoint point: CGFloat) -> CGFloat {
        let sliderMidPoint = point - (SLIDER_BUTTON_WIDTH / 2)
        return sliderMidPoint
    }
    
    private func getSegmentWidth(forSegmentCount segmentCount: Int) -> CGFloat {
        let segmentCount = CGFloat(segmentCount - 1)
        let sliderWidth = frame.width - SLIDER_BUTTON_WIDTH
        return sliderWidth / segmentCount
    }
    
    private func getSegmentButton(withSegmentIndex segmentIndex: Int, isSlider: Bool) -> UIButton {
        // Create rounded button for representing slider segments
        let segmentButton = UIButton(type: .custom)
        segmentButton.frame = CGRect(x: 0, y: 0, width: SLIDER_BUTTON_WIDTH, height: SLIDER_BUTTON_WIDTH)
        segmentButton.center = getSegmentCenterPoint(forSegmentIndex: segmentIndex)
        setImageForSegmentOrSliderButton(segmentButton, isSlider: isSlider)
        return segmentButton
    }
    
    // MARK: - Calculation for segment button frame
    private func getSegmentCenterPoint(forSegmentIndex segmentIndex: Int) -> CGPoint {
        let pointX = CGFloat((CGFloat(segmentIndex - 1) * segmentWidth) + (SLIDER_BUTTON_WIDTH / 2))
        return CGPoint(x: pointX, y: sliderBackgroundView.frame.midY)
    }
    
    private func setImageForSegmentOrSliderButton(_ button: UIButton, isSlider: Bool) {
        if let image = rangeSliderButtonImage, isSlider {
            button.setImage(image, for: .normal)
        } else if let iamge = segmentSelectedImage {
            button.setImage(iamge, for: .normal)
        } else {
            button.setImage(getImageWithSize(isSlider ? sliderSize : segmentSize, with: segmentSelectedColor), for: .normal)
        }
        
        button.imageView?.layer.masksToBounds = true
        let buttonWidth = button.imageView?.frame.size.width ?? button.frame.size.width
        button.imageView?.layer.cornerRadius = buttonWidth / 2
    }
    
    private func getImageWithSize(_ size: CGSize, with backgroundColor: UIColor?) -> UIImage? {
        let imageView = UIView(frame: CGRect(x: 0, y: 0, width: size.width, height: size.height))
        imageView.backgroundColor = backgroundColor
        
        UIGraphicsBeginImageContextWithOptions(CGSize(width: size.width, height: size.height), false, 1.0)
        if let context = UIGraphicsGetCurrentContext() {
            imageView.layer.render(in: context)
        }
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return image
    }
}

6. Tạo các hàm để tính toán vị trí và xử lí delegate của Pangesture

extension TWSlider {
    
    private func addPanGestureRecognizer() {
        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture))
        panGesture.maximumNumberOfTouches = 1
        addGestureRecognizer(panGesture)
    }
    
    // Pan Gesture selector method
    
    @objc
    func handlePanGesture(_ panGesture: UIPanGestureRecognizer) {
        let point = panGesture.location(in: self)
        
        if panGesture.state == .began {
            self.setSelectedStateForSlidingButton(point)
        } else if panGesture.state == .changed {
            sliderDidSlide(for: point)
        } else if panGesture.state == .ended || panGesture.state == .failed || panGesture.state == .cancelled {
            // Move the slider to nearest segment
            moveSliderToNearestSegment(withEnding: point)
            resetSelectedStateForSlidingButtons()
        }
    }
    
    // If sliding began, check if startSlider is moved or endSlider is moved
    private  func setSelectedStateForSlidingButton(_ point: CGPoint) {
        if startSliderButton.frame.contains(point) {
            startSliderButton.isSelected = true
            endSliderButton.isSelected = false
        } else if endSliderButton.frame.contains(point) {
            endSliderButton.isSelected = true
            startSliderButton.isSelected = false
        } else {
            startSliderButton.isSelected = false
            endSliderButton.isSelected = false
        }
    }
    
    private func sliderDidSlide(for point: CGPoint) {
        var newPoint = point
        // Check if startButton is moved or endButton is moved. Based on the moved button, set the frame of the slider button and foregroundSliderView
        newPoint = resetFrameOnBoundsCross(for: point)
        
        if startSliderButton.isSelected {
            if shouldStartButtonSlide(for: newPoint) {
                UIView.animate(withDuration: 0.1, animations: {
                    // Change only the x value for startbutton
                    self.startSliderButton.frame = CGRect(x: self.sliderMidPoint(forPoint: newPoint.x), y: self.startSliderButton.frame.origin.y, width: self.startSliderButton.frame.size.width, height: self.startSliderButton.frame.size.height)
                    
                    // Change the x and width for slider foreground view
                    if self.shouldSliderButtonOverlap {
                        let startMidX = self.startSliderButton.frame.midX
                        let endMidX = self.endSliderButton.frame.midX
                        var originX = self.startSliderButton.frame.origin.x
                        if endMidX < startMidX {
                            originX = self.endSliderButton.frame.origin.x
                        }
                        self.sliderForegroundView.frame = CGRect(x: originX + self.SLIDER_BUTTON_WIDTH / 2, y: self.sliderForegroundView.frame.origin.y, width: self.getSliderViewWidth(), height: self.sliderForegroundView.frame.size.height)
                    } else {
                        let originX = self.startSliderButton.frame.origin.x + self.SLIDER_BUTTON_WIDTH / 2
                        self.sliderForegroundView.frame = CGRect(x: originX, y: self.sliderForegroundView.frame.origin.y, width: self.getSliderViewWidth(), height: self.sliderForegroundView.frame.size.height)
                    }
                    
                    // Change the x and width for slider foreground view
                    self.minRangeView.center = CGPoint(x: self.startSliderButton.frame.midX, y: self.startSliderButton.frame.minY - self.paddingRangeView)
                    
                    self.sliderMidView.center = CGPoint(x: self.endSliderButton.frame.midX, y: self.startSliderButton.frame.minY - self.paddingRangeView)
                    
                    // Update the intermediate segment colors
                    self.updateSegmentColor(for: newPoint)
                }, completion: { _ in
                    self.callScrollDelegate(point: newPoint, isStartSliderButton: true)
                })
            }
        } else if endSliderButton.isSelected {
            if shouldEndButtonSlide(for: newPoint) {
                UIView.animate(withDuration: 0.1, animations: {
                    // Change only the x value for endbutton
                    self.endSliderButton.frame = CGRect(x: self.sliderMidPoint(forPoint: newPoint.x), y: self.endSliderButton.frame.origin.y, width: self.endSliderButton.frame.size.width, height: self.endSliderButton.frame.size.height)
                    
                    // Change the x and width for slider foreground view
                    if self.shouldSliderButtonOverlap {
                        let startMidX = self.startSliderButton.frame.midX
                        let endMidX = self.endSliderButton.frame.midX
                        var originX = self.startSliderButton.frame.origin.x
                        if endMidX < startMidX {
                            originX = self.endSliderButton.frame.origin.x
                        }
                        self.sliderForegroundView.frame = CGRect(x: originX + self.SLIDER_BUTTON_WIDTH / 2, y: self.sliderForegroundView.frame.origin.y, width: self.getSliderViewWidth(), height: self.sliderForegroundView.frame.size.height)
                    } else {
                        let originX = self.startSliderButton.frame.origin.x + self.SLIDER_BUTTON_WIDTH / 2
                        self.sliderForegroundView.frame = CGRect(x: originX, y: self.sliderForegroundView.frame.origin.y, width: self.getSliderViewWidth(), height: self.sliderForegroundView.frame.size.height)
                    }
                    
                    // Change the x and width for slider foreground view
                    self.maxRangeView.center = CGPoint(x: self.endSliderButton.frame.midX, y: self.endSliderButton.frame.minY - self.paddingRangeView)
                    
                    self.sliderMidView.center = CGPoint(x: self.startSliderButton.frame.midX, y: self.endSliderButton.frame.minY - self.paddingRangeView)
                    
                    // Update the intermediate segment colors
                    self.updateSegmentColor(for: newPoint)
                }, completion: { _ in
                    self.callScrollDelegate(point: newPoint, isStartSliderButton: false)
                })
            }
        }
    }
    
    // Method that handles if the sliders move out of range
    private func resetFrameOnBoundsCross(for point: CGPoint) -> CGPoint {
        var newPoint = point
        if shouldSliderButtonOverlap {
            if point.x < 0 {
                newPoint.x = 0
            } else if sliderMidPoint(forPoint: point.x) >= sliderBackgroundView.bounds.maxX {
                newPoint.x = sliderBackgroundView.bounds.maxX + SLIDER_BUTTON_WIDTH / 2
            }
        } else {
            if startSliderButton.isSelected {
                if sliderMidPoint(forPoint: point.x) >= endSliderButton.frame.midX - segmentWidth {
                    newPoint.x = endSliderButton.frame.midX - segmentWidth
                } else if point.x < 0 {
                    newPoint.x = 0
                }
            } else if endSliderButton.isSelected {
                if point.x <= startSliderButton.frame.midX + segmentWidth {
                    newPoint.x = startSliderButton.frame.midX + segmentWidth
                } else if sliderMidPoint(forPoint: point.x) >= sliderBackgroundView.bounds.maxX {
                    newPoint.x = sliderBackgroundView.bounds.maxX + SLIDER_BUTTON_WIDTH / 2
                }
            }
        }
        
        return newPoint
    }
    
    private func shouldStartButtonSlide(for point: CGPoint) -> Bool {
        if shouldSliderButtonOverlap {
            return (point.x >= (SLIDER_BUTTON_WIDTH / 2)) && (point.x <= (bounds.maxX - SLIDER_BUTTON_WIDTH / 2))
        } else {
            var endButtonMidPoint = endSliderButton.frame.midX
            endButtonMidPoint -= segmentWidth
            return round(point.x) <= round(endButtonMidPoint) && point.x >= SLIDER_BUTTON_WIDTH / 2
        }
    }
    
    private func shouldEndButtonSlide(for point: CGPoint) -> Bool {
        if shouldSliderButtonOverlap {
            return point.x >= SLIDER_BUTTON_WIDTH / 2
        } else {
            var startButtonMidPoint = startSliderButton.frame.midX
            startButtonMidPoint += shouldSliderButtonOverlap ? 0 : segmentWidth
            return round(point.x) >= round(startButtonMidPoint) && point.x <= sliderMidPoint(forPoint: frame.size.width)
        }
    }
    
    // Call the delegate to set the label for min range and max range
    private func callScrollDelegate(point: CGPoint, isStartSliderButton: Bool) {
        let nearestSegmentIndex = Int(round(sliderMidPoint(forPoint: point.x) / segmentWidth))
        
        var startIndex = Int(round(startSliderButton.frame.minX / segmentWidth))
        var endIndex = Int(round(endSliderButton.frame.minX / segmentWidth))
        
        if isStartSliderButton {
            startIndex = nearestSegmentIndex
        } else {
            endIndex = nearestSegmentIndex
        }
        updateData(startIndex: startIndex, endIndex: endIndex, endDragDrop: false)
    }
    
    private func updateData(startIndex: Int, endIndex: Int, endDragDrop: Bool) {
        if startIndex > endIndex {
            let min = endIndex < 0 ? 0 : endIndex
            let max = startIndex > numberOfSegments ? numberOfSegments : startIndex
            delegate?.sliderScrolled(self, toMinIndex: min, andMaxIndex: max, endDragDrop: endDragDrop)
            minRangeLabel.text = maxRangeText
            maxRangeLabel.text = minRangeText
        } else {
            let min = startIndex < 0 ? 0 : startIndex
            let max = endIndex > numberOfSegments ? numberOfSegments : endIndex
            delegate?.sliderScrolled(self, toMinIndex: min, andMaxIndex: max, endDragDrop: endDragDrop)
            minRangeLabel.text = minRangeText
            maxRangeLabel.text = maxRangeText
        }
        
        if startIndex != endIndex {
            UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseIn) {
                self.sliderMidView.alpha = 0
                self.minRangeView.alpha = 1
                self.maxRangeView.alpha = 1
            } completion: { _ in
                print("completion \(startIndex) = \(endIndex)")
            }
        } else {
            sliderMidLabel.text = "\(minRangeText ?? "") - \(maxRangeText ?? "")"
            UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseIn) {
                self.sliderMidView.alpha = 1
                self.minRangeView.alpha = 0
                self.maxRangeView.alpha = 0
            } completion: { _ in
                print("completion \(startIndex) = \(endIndex)")
            }
        }
    }
    
    private func updateSegmentColor(for point: CGPoint) {
        
        if shouldSliderButtonOverlap {
            let startMinX = startSliderButton.frame.midX
            let endMinX = endSliderButton.frame.midX
            
            var min: CGFloat = 0.0
            var max: CGFloat = 0.0
            if startMinX > endMinX {
                min = (endSliderButton.frame.minX / segmentWidth).rounded(.up)
                max = (startSliderButton.frame.minX / segmentWidth).rounded(.down)
            } else {
                min = (startSliderButton.frame.minX / segmentWidth).rounded(.up)
                max = (endSliderButton.frame.minX / segmentWidth).rounded(.down)
            }
            updateSegmentColor(withStart: Int(min) + 1, andEnd: Int(max) + 1)
        } else {
            if startSliderButton.isSelected {
                let startMinX = (sliderMidPoint(forPoint: startSliderButton.frame.midX) / segmentWidth).rounded(.up)
                let endMinX = (sliderMidPoint(forPoint: endSliderButton.frame.midX) / segmentWidth).rounded(.up)
                updateSegmentColor(withStart: Int(startMinX) + 1, andEnd: Int(endMinX) + 1)
            } else if endSliderButton.isSelected {
                let startMinX = (startSliderButton.frame.minX / segmentWidth).rounded(.up)
                let endMinX = (endSliderButton.frame.minX / segmentWidth).rounded(.down)
                updateSegmentColor(withStart: Int(startMinX) + 1, andEnd: Int(endMinX) + 1)
            }
        }
        let pointX: Int = Int(round(point.x - SLIDER_BUTTON_WIDTH / 2))
        if pointX % Int(round(segmentWidth)) == 0 {
            let generator = UIImpactFeedbackGenerator(style: .medium)
            generator.impactOccurred()
        }
    }
    
    private func updateSegmentColor(withStart startIndex: Int, andEnd endIndex: Int) {
        // Segments before startSegment slider
        
        if startIndex > 1 {
            for segmentIndex in 1..<startIndex {
                if let segmentButton = viewWithTag(segmentIndex) as? UIButton {
                    if let image = segmentUnSelectedImage {
                        segmentButton.setImage(image, for: .normal)
                    } else {
                        let image = getImageWithSize(segmentSize, with: segmentUnSelectedColor)
                        segmentButton.setImage(image, for: .normal)
                    }
                }
            }
        }
        
        // Segments between startSegment slider and endSegment slider
        if startIndex <= endIndex {
            for segmentIndex in startIndex...endIndex {
                if let segmentButton = viewWithTag(segmentIndex) as? UIButton {
                    if let image = segmentSelectedImage {
                        segmentButton.setImage(image, for: .normal)
                    } else {
                        let image = getImageWithSize(segmentSize, with: segmentSelectedColor)
                        segmentButton.setImage(image, for: .normal)
                    }
                }
            }
        }
        
        // Segments after endSegment slider
        if endIndex + 1 <= numberOfSegments {
            for segmentIndex in (endIndex + 1)...numberOfSegments {
                if let segmentButton = viewWithTag(segmentIndex) as? UIButton {
                    if let image = segmentUnSelectedImage {
                        segmentButton.setImage(image, for: .normal)
                    } else {
                        let image = getImageWithSize(segmentSize, with: segmentUnSelectedColor)
                        segmentButton.setImage(image, for: .normal)
                    }
                }
            }
        }
    }
    
    // Slide to nearest position
    private func moveSliderToNearestSegment(withEnding point: CGPoint) {
        var newPoint = point
        newPoint = resetFrameOnBoundsCross(for: point)
        
        let nearestSegmentIndex = Int(round(sliderMidPoint(forPoint: newPoint.x) / segmentWidth))
        sliderDidSlide(for: CGPoint(x: CGFloat(SLIDER_BUTTON_WIDTH / 2 + CGFloat(nearestSegmentIndex) * segmentWidth), y: newPoint.y))
        
        var startIndex = Int(round(startSliderButton.frame.minX / segmentWidth))
        var endIndex = Int(round(endSliderButton.frame.minX / segmentWidth))
        
        if startSliderButton.isSelected {
            startIndex = nearestSegmentIndex
        } else if endSliderButton.isSelected {
            endIndex = nearestSegmentIndex
        }
        updateData(startIndex: startIndex, endIndex: endIndex, endDragDrop: true)
    }
    
    // After ending, reset the selected state of both buttons
    private func resetSelectedStateForSlidingButtons() {
        startSliderButton.isSelected = false
        endSliderButton.isSelected = false
    }
}

5. Tạo các hàm public cho phép thay đổi giá trị của TWSlider

extension TWSlider {
    
    // Scroll to desired location on loading
    func scrollStartSlider(to startIndex: Int, andEnd endIndex: Int) {
        minRangeInitialIndex = startIndex
        maxRangeInitialIndex = endIndex
        if checkInit {
            slideRangeSliderButtonsIfNeeded()
        }
    }
    
    // MARK: - Setter methods, setup value for slider
    func setNumberOfSegments(_ numberOfSegments: Int, minText: String?, maxText: String?) {
        self.numberOfSegments = numberOfSegments
        
        // After setting the numberOfSegments, set all the necessary views
        segmentWidth = getSegmentWidth(forSegmentCount: self.numberOfSegments)
        addSegmentButtons()
        addSubview(startSliderButton)
        addSubview(endSliderButton)
        minLabel.text = minText
        maxLabel.text = maxText
    }
    
    private func addSegmentButtons() {
        for segmentIndex in 1...numberOfSegments {
            let segmentButton = getSegmentButton(withSegmentIndex: segmentIndex, isSlider: false)
            segmentButton.then {
                $0.tag = segmentIndex
                $0.isUserInteractionEnabled = false
                addSubview($0)
            }
        }
    }
}

3. Cách sử dụng

Bước 1: mở file storyboard hoặc file .xib và thêm UIView và constraint, sửa lại tên class thành class custom slider mới tạo ở trên ở project này là TWSlider -> Kéo outlet

Bước 2: mở viewcontroller vừa được kéo outlet viết hàm setup cho slider như sau

class ViewController: UIViewController {

    @IBOutlet weak var customSlider: TWSlider!
    private let data: [String] = ["10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20"]
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        customSlider.then {
            $0.shouldSliderButtonOverlap = true
            $0.delegate = self
        }
        customSlider.then {
            $0.setNumberOfSegments(data.count, minText: data.first, maxText: data.last)
            $0.scrollStartSlider(to: 0 + 1, andEnd: 10 + 1)
        }
    }
}

extension ViewController: TWSliderDelegate {
    func sliderScrolled(_ slider: TWSlider?, toMinIndex minIndex: Int, andMaxIndex maxIndex: Int, endDragDrop: Bool) {
        customSlider.minRangeText = data[minIndex]
        customSlider.maxRangeText = data[maxIndex]
    }
}

Bạn có thể tuỳ chỉnh số hiển thị trên slider bằng các số tương ứng với project.

Mình để project ở đây cho mọi người tham khảo nếu cần nhé!

Vậy là chúng ta đã hoàn thành việc custom Slider hai chiều, mình hi vọng nó có thể giải quyết được bài toán của các bạn. Cảm ơn các bạn đã theo dõi bài viết!

Leave a Comment

* By using this form you agree with the storage and handling of your data by this website.

You may also like