iOS/UIKit

[iOS / UIKit] Custom UISegmentedControls with UIPageViewcontroller

TDCIAN 2025. 5. 17. 21:19

Underline Type

 

Custom CornerRadius Type

 

 

(1) MainViewController.swift

import UIKit
import SnapKit
import RxSwift
import RxCocoa

class MainViewController: UIViewController {
    
    private lazy var underlineSegmentedControl: UISegmentedControl = {
        let segmentedControl = UnderlineSegmentedControl(items: ["First", "Second", "Third"])
        segmentedControl.selectedSegmentIndex = 0
        segmentedControl.setTitleTextAttributes(
            [
                NSAttributedString.Key.foregroundColor: UIColor.gray,
                NSAttributedString.Key.font: UIFont.systemFont(ofSize: 16, weight: .semibold)
            ],
            for: .normal
        )
        segmentedControl.setTitleTextAttributes(
            [
                NSAttributedString.Key.foregroundColor: UIColor.systemBlue,
                NSAttributedString.Key.font: UIFont.systemFont(ofSize: 16, weight: .bold)
            ],
            for: .selected
        )
        return segmentedControl
    }()
    
    private lazy var customCornerRadiusSegmentedControl: UISegmentedControl = {
        let segmentedControl = CustomCornerRadiusSegmentedControl(items: ["First", "Second", "Third"])
        segmentedControl.customCornerRadius = 24
        segmentedControl.selectedBackgroundColor = .white
        segmentedControl.normalBackgroundColor = .systemGray5
        segmentedControl.borderColor = .systemGray5
        segmentedControl.borderWidth = 1.5
        
        segmentedControl.selectedSegmentIndex = 0
        segmentedControl.setTitleTextAttributes(
            [
                NSAttributedString.Key.foregroundColor: UIColor.gray,
                NSAttributedString.Key.font: UIFont.systemFont(ofSize: 16, weight: .semibold)
            ],
            for: .normal
        )
        segmentedControl.setTitleTextAttributes(
            [
                NSAttributedString.Key.foregroundColor: UIColor.systemBlue,
                NSAttributedString.Key.font: UIFont.systemFont(ofSize: 16, weight: .bold)
            ],
            for: .selected
        )
        return segmentedControl
    }()
    
    private let firstViewController: FirstViewController = FirstViewController()
    private let secondViewController: SecondViewController = SecondViewController()
    private let thirdViewController: ThirdViewController = ThirdViewController()

    private var dataViewControllers: [UIViewController] {
        return [firstViewController, secondViewController, thirdViewController]
    }

    private lazy var pageViewController: UIPageViewController = {
        let pageViewController = UIPageViewController(
            transitionStyle: .scroll,
            navigationOrientation: .horizontal,
            options: nil
        )
        pageViewController.setViewControllers(
            [dataViewControllers[0]],
            direction: .forward,
            animated: false,
            completion: nil
        )
        pageViewController.dataSource = self
        pageViewController.delegate = self
        return pageViewController
    }()
    
    private var currentPage: Int = 0 {
        didSet {
            // from segmentedControl -> pageViewController update
            print("### currentPage - oldValue: \(oldValue), currentPage: \(currentPage)")
            
            let direction: UIPageViewController.NavigationDirection = oldValue <= currentPage ? .forward : .reverse
            
            pageViewController.setViewControllers(
                [dataViewControllers[currentPage]],
                direction: direction,
                animated: true,
                completion: nil
            )
        }
    }
    
    private let disposeBag: DisposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // MARK: UnderlineSegmentedControl
        setUIWithUnderlineSegmentedControl()
        bindWithUnderlineSegmentedControl()
        
        // MARK: CustomCornerRadiusSegmentedControl
//        setUIWithCustomCornerRadiusSegmentedControl()
//        bindWithCustomCornerRadiusSegmentedControl()
    }

    private func setUIWithUnderlineSegmentedControl() {
        view.addSubview(underlineSegmentedControl)
        view.addSubview(pageViewController.view)
        
        underlineSegmentedControl.snp.makeConstraints { make in
            make.top.equalTo(view.safeAreaLayoutGuide.snp.top)
            make.horizontalEdges.equalToSuperview().inset(4)
            make.height.equalTo(50)
        }
        
        pageViewController.view.snp.makeConstraints { make in
            make.top.equalTo(underlineSegmentedControl.snp.bottom).offset(5)
            make.horizontalEdges.equalToSuperview()
            make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom)
        }
    }
    
    private func bindWithUnderlineSegmentedControl() {
        underlineSegmentedControl.rx.selectedSegmentIndex
            .distinctUntilChanged()
            .observe(on: MainScheduler.instance)
            .subscribe(with: self, onNext: { owner, selectedIndex in
                print("### subscribe - selectedIndex: \(selectedIndex)")
                owner.currentPage = selectedIndex
            })
            .disposed(by: disposeBag)
    }
    
    private func setUIWithCustomCornerRadiusSegmentedControl() {
        view.addSubview(customCornerRadiusSegmentedControl)
        view.addSubview(pageViewController.view)
        
        customCornerRadiusSegmentedControl.snp.makeConstraints { make in
            make.top.equalTo(view.safeAreaLayoutGuide.snp.top)
            make.horizontalEdges.equalToSuperview().inset(4)
            make.height.equalTo(50)
        }
        
        pageViewController.view.snp.makeConstraints { make in
            make.top.equalTo(customCornerRadiusSegmentedControl.snp.bottom).offset(5)
            make.horizontalEdges.equalToSuperview()
            make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom)
        }
    }
    
    private func bindWithCustomCornerRadiusSegmentedControl() {
        customCornerRadiusSegmentedControl.rx.selectedSegmentIndex
            .distinctUntilChanged()
            .observe(on: MainScheduler.instance)
            .subscribe(with: self, onNext: { owner, selectedIndex in
                print("### subscribe - selectedIndex: \(selectedIndex)")
                owner.currentPage = selectedIndex
            })
            .disposed(by: disposeBag)
    }
}

extension MainViewController: UIPageViewControllerDataSource {
    
    
    /*
     MARK: 한국어
     기능: 현재 보여지고 있는 뷰 컨트롤러 기준으로 이전(왼쪽) 뷰 컨트롤러를 반환
     호출 시점: 사용자가 이전(왼쪽)으로 스와이프할 때 호출
     반환값: 현재 뷰 컨트롤러가 첫 번째라면(nil 반환), 더 이상 이전 페이지가 없으므로 스와이프가 멈춤
     
     MARK: English
     Function: Returns the previous (left) view controller based on the currently displayed view controller
     When called: Called when the user swipes to the previous (left) page
     Return value: Returns nil if the current view controller is the first one, so swiping stops as there are no more previous pages
     */
    func pageViewController(
        _ pageViewController: UIPageViewController,
        viewControllerBefore viewController: UIViewController
    ) -> UIViewController? {
        
        guard let index = dataViewControllers.firstIndex(of: viewController),
              index - 1 >= 0
        else { return nil }
        
        return dataViewControllers[index - 1]
    }
    
    /*
     MARK: 한국어
     기능: 현재 보여지고 있는 뷰 컨트롤러 기준으로 다음(오른쪽) 뷰 컨트롤러를 반환
     호출 시점: 사용자가 다음(오른쪽)으로 스와이프할 때 호출
     반환값: 현재 뷰 컨트롤러가 마지막이라면(nil 반환), 더 이상 다음 페이지가 없으므로 스와이프가 멈춤

     MARK: English
     Function: Returns the next (right) view controller based on the currently displayed view controller
     When called: Called when the user swipes to the next (right) page
     Return value: Returns nil if the current view controller is the last one, so swiping stops as there are no more next pages
     */
    func pageViewController(
        _ pageViewController: UIPageViewController,
        viewControllerAfter viewController: UIViewController
    ) -> UIViewController? {
        
        guard let index = dataViewControllers.firstIndex(of: viewController),
              index + 1 < dataViewControllers.count
        else { return nil }
        
        return dataViewControllers[index + 1]
    }
}

extension MainViewController: UIPageViewControllerDelegate {
    /*
     MARK: 한국어
     기능: 사용자가 페이지를 스와이프(드래그)해서 이동하는 애니메이션이 끝났을 때 호출됨

     MARK: English
     Function: Called when the animation for swiping (dragging) between pages finishes
     */
    func pageViewController(
        _ pageViewController: UIPageViewController,
        didFinishAnimating finished: Bool,
        previousViewControllers: [UIViewController],
        transitionCompleted completed: Bool
    ) {
        guard let viewController = pageViewController.viewControllers?.first,
              let index = dataViewControllers.firstIndex(of: viewController)
        else { return }
        
        currentPage = index
        
        underlineSegmentedControl.selectedSegmentIndex = index
        customCornerRadiusSegmentedControl.selectedSegmentIndex = index
    }
}

 

 

(2) UnderlineSegmentedControl.swift

import UIKit

final class UnderlineSegmentedControl: UISegmentedControl {
    
    private lazy var underlineView: UIView = {
        let width: CGFloat = self.bounds.width / CGFloat(self.numberOfSegments)
        let height: CGFloat = 5
        let xPosition: CGFloat = CGFloat(self.selectedSegmentIndex * Int(width))
        let yPosition: CGFloat = self.bounds.height - height
        
        let frame: CGRect = CGRect(
            x: xPosition,
            y: yPosition,
            width: width,
            height: height
        )
        
        let view = UIView(frame: frame)
        view.backgroundColor = .systemBlue
        
        self.addSubview(view)
        self.clipsToBounds = false
        
        return view
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        removeBackgroundAndDivider()
    }
    
    override init(items: [Any]?) {
        super.init(items: items)

        removeBackgroundAndDivider()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func removeBackgroundAndDivider() {
        let image = UIImage()
        self.setBackgroundImage(image, for: .normal, barMetrics: .default)
        self.setBackgroundImage(image, for: .selected, barMetrics: .default)
        self.setBackgroundImage(image, for: .highlighted, barMetrics: .default)
        
        self.setDividerImage(image, forLeftSegmentState: .selected, rightSegmentState: .normal, barMetrics: .default)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        let underlineFinalXPosition: CGFloat = (self.bounds.width / CGFloat(self.numberOfSegments)) * CGFloat(self.selectedSegmentIndex)
        
        UIView.animate(
            withDuration: 0.1,
            animations: {
                self.underlineView.frame.origin.x = underlineFinalXPosition
            }
        )
    }
}

 

(3) CustomCornerRadiusSegmentedControl.swift

import UIKit

// UIColor를 UIImage로 변환하는 확장 (필수)
extension UIImage {
    convenience init?(color: UIColor, size: CGSize = CGSize(width: 1, height: 1)) {
        let rect = CGRect(origin: .zero, size: size)
        UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0)
        color.setFill()
        UIRectFill(rect)
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        guard let cgImage = image?.cgImage else { return nil }
        self.init(cgImage: cgImage)
    }
}

class CustomCornerRadiusSegmentedControl: UISegmentedControl {
    // 원하는 inset 및 cornerRadius 값 지정
    var segmentInset: CGFloat = 4
    var customCornerRadius: CGFloat = 16 // 원하는 값으로 변경

    var selectedBackgroundColor: UIColor = .white
    var normalBackgroundColor: UIColor = .systemGray5
    var borderColor: UIColor = .systemGray3
    var borderWidth: CGFloat = 1

    override func layoutSubviews() {
        super.layoutSubviews()

        // 전체 배경 및 border
        self.backgroundColor = normalBackgroundColor
        self.layer.cornerRadius = customCornerRadius
        self.layer.borderWidth = borderWidth
        self.layer.borderColor = borderColor.cgColor
        self.layer.masksToBounds = true

        // 선택된 segment 배경 커스텀
        let foregroundIndex = self.numberOfSegments
        
        if self.subviews.indices.contains(foregroundIndex),
           let foregroundImageView = self.subviews[foregroundIndex] as? UIImageView {
            foregroundImageView.bounds = foregroundImageView.bounds.insetBy(dx: segmentInset, dy: segmentInset)
            foregroundImageView.image = UIImage(color: selectedBackgroundColor)
            foregroundImageView.layer.removeAnimation(forKey: "SelectionBounds")
            foregroundImageView.layer.masksToBounds = true
            foregroundImageView.layer.cornerRadius = customCornerRadius - segmentInset
        }
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)
    }
}

 

(4) FirstViewController.swift

import UIKit

final class FirstViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemRed
        
        print("### FirstViewController - viewDidLoad")
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        print("### FirstViewController - viewDidAppear")
    }
    
}

 

(5) SecondViewController.swift

import UIKit

final class SecondViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .green
        
        print("### SecondViewController viewDidLoad")
    }
 
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        print("### SecondViewController - viewDidAppear")
    }
}

 

(6) ThirdViewController.swift

import UIKit

final class ThirdViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .yellow
        
        print("### ThirdViewController viewDidLoad")
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        print("### ThirdViewController - viewDidAppear")
    }
}

 

You can download full source code here

: https://github.com/TDCIAN/CustomSegmentedControl

 

 

Reference:

- UnderlineSegmentedControl: https://ios-development.tistory.com/963

- CustomCornerRadius SegmentedControl: https://medium.com/poatek/customizing-segmented-control-in-swiftui-and-uikit-bee26e52a561