Red(레드)
RED BITES APPLE
Red(레드)
전체 방문자
오늘
어제
  • 분류 전체보기 (22)
    • 🍎 iOS (20)
      • 🕊️ swift (9)
      • ⌨️ xcode (1)
      • 🍏 UIKit (7)
      • 🍑 SwiftUI (0)
      • 🧑🏻‍💻 WWDC (3)
    • 🖥️ CS (1)
    • 📘 용어 사전 (1)
    • TIL (0)

블로그 메뉴

  • POST
  • Git Hub

공지사항

인기 글

태그

  • til
  • meta

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
Red(레드)

RED BITES APPLE

인스타그램 스타일 Page Control
🍎 iOS/🍏 UIKit

인스타그램 스타일 Page Control

2023. 7. 4. 03:39

인스타그램 스타일의 페이지 컨트롤을 만들려고 했다.

UIPageControl

처음엔 UIPageControl 을 이용해서 만들려고 했지만,

  1. 보여 줄 수 있는 점의 개수와 종류를 지정할 수 없음
  2. 점의 크기를 지정할 수 없음

위의 두가지 이유로 UIPageControl 을 사용할 수 없었다.

UIControl

애초에 UIPageControl 이 UIControl을 상속하고 있기 때문에, UIControl을 상속해서 만들어 보았다.

UIControl 공식 문서

공식 문서를 보면, UIControl 은 버튼이나, 슬라이더, 텍스트 인풋 같이 유저가 인터렉션 할 수 있게 하는 요소들이 상속을 받고 있다.

UIControl은 사용자 인터페이스 요소를 구성하고 사용자의 상호작용을 처리하는 데 사용되는 클래스이다. UIControl은 다양한 이벤트에 대한 응답을 처리하고, 사용자의 입력을 감지하고 제어할 수 있는 다양한 컨트롤 요소를 제공한다.

우리가 아는 UIButton, UISlider, UITextField, UIPageControl 등등이 모두 UIControl 을 상속받고 있다.

UIControl의 내부 구현을 보자.

다양한 Event 들 그리고 State 들이 존재하는 것을 알 수 있다.

extension UIControl {
    public struct Event : OptionSet, @unchecked Sendable {

        public init(rawValue: UInt)

        public static var touchDown: UIControl.Event { get }
        public static var touchDownRepeat: UIControl.Event { get }

        ...

    }


    public enum ContentVerticalAlignment : Int, @unchecked Sendable {
        case center = 0
        case top = 1
        case bottom = 2
        case fill = 3
    }


    public enum ContentHorizontalAlignment : Int, @unchecked Sendable {
        case center = 0
        case left = 1

        ...

    }


    public struct State : OptionSet, @unchecked Sendable {

        public init(rawValue: UInt)

        public static var normal: UIControl.State { get }
        public static var highlighted: UIControl.State { get } // used when UIControl isHighlighted is set

        ...

    }
}

중요한 건, UIControl.Event들이 그리고 State 들이 정의 되어 있고 이 동작에 따른 함수들도 정의 되어 있어서, 그런 메서드들을 상속 받아서 컨트롤을 구현하면 된다는 것이다.

그런데 쌩으로 페이지 컨트롤을 만들려고 하면 생각해야 할 것이 많아서, UIPageControl 이 어떻게 구현되어 있는지 보고 배끼기로 했다.

@available(iOS 2.0, *)
@MainActor open class UIPageControl : UIControl {

    /// default is 0
    open var numberOfPages: Int

    /// default is 0. Value is pinned to 0..numberOfPages-1
    open var currentPage: Int

    /// hides the indicator if there is only one page, default is NO
    open var hidesForSinglePage: Bool

    /// The tint color for non-selected indicators. Default is nil.
    @available(iOS 6.0, *)
    open var pageIndicatorTintColor: UIColor?

    /// The tint color for the currently-selected indicators. Default is nil.
    @available(iOS 6.0, *)
    open var currentPageIndicatorTintColor: UIColor?

    /// The preferred background style. Default is UIPageControlBackgroundStyleAutomatic on iOS, and UIPageControlBackgroundStyleProminent on tvOS.
    @available(iOS 14.0, *)
    open var backgroundStyle: UIPageControl.BackgroundStyle

    /// The layout direction of the page indicators. The default value is \c UIPageControlDirectionNatural.
    @available(iOS 16.0, *)
    open var direction: UIPageControl.Direction

    /// The current interaction state for when the current page changes. Default is UIPageControlInteractionStateNone
    @available(iOS 14.0, *)
    open var interactionState: UIPageControl.InteractionState { get }


    /// Returns YES if the continuous interaction is enabled, NO otherwise. Default is YES.
    @available(iOS 14.0, *)
    open var allowsContinuousInteraction: Bool


    /// The preferred image for indicators. Symbol images are recommended. Default is nil.
    @available(iOS 14.0, *)
    open var preferredIndicatorImage: UIImage?


    /**
     * @abstract Returns the override indicator image for the specific page, nil if no override image was set.
     * @param page Must be in the range of 0..numberOfPages
     */
    @available(iOS 14.0, *)
    open func indicatorImage(forPage page: Int) -> UIImage?


    /**
     * @abstract Override the indicator image for a specific page. Symbol images are recommended.
     * @param image     The image for the indicator. Resets to the default if image is nil.
     * @param page      Must be in the range of 0..numberOfPages
     */
    @available(iOS 14.0, *)
    open func setIndicatorImage(_ image: UIImage?, forPage page: Int)


    /// The preferred image for the current page indicator. Symbol images are recommended. Default is nil.
    /// If this value is nil, then UIPageControl will use \c preferredPageIndicatorImage (or its per-page variant) as
    /// the indicator image.
    @available(iOS 16.0, *)
    open var preferredCurrentPageIndicatorImage: UIImage?


    /**
     * @abstract Returns the override current page indicator image for the specific page, nil if no override image was set.
     * @param page Must be in the range of 0..numberOfPages
     */
    @available(iOS 16.0, *)
    open func currentPageIndicatorImage(forPage page: Int) -> UIImage?


    /**
     * @abstract Override the current page indicator image for a specific page. Symbol images are recommended.
     * @param image     The image for the indicator. Resets to the default if image is nil.
     * @param page      Must be in the range of 0..numberOfPages
     */
    @available(iOS 16.0, *)
    open func setCurrentPageIndicatorImage(_ image: UIImage?, forPage page: Int)


    /// Returns the minimum size required to display indicators for the given page count. Can be used to size the control if the page count could change.
    open func size(forNumberOfPages pageCount: Int) -> CGSize


    /// if set, tapping to a new page won't update the currently displayed page until -updateCurrentPageDisplay is called. default is NO
    @available(iOS, introduced: 2.0, deprecated: 14.0, message: "defersCurrentPageDisplay no longer does anything reasonable with the new interaction mode.")
    open var defersCurrentPageDisplay: Bool


    /// update page display to match the currentPage. ignored if defersCurrentPageDisplay is NO. setting the page value directly will update immediately
    @available(iOS, introduced: 2.0, deprecated: 14.0, message: "updateCurrentPageDisplay no longer does anything reasonable with the new interaction mode.")
    open func updateCurrentPageDisplay()
}

이런 사항들이 구현되어 있다고 하는데

축약하자면,

  • numberOfPages
  • currentPage
  • hidesForSinglePage
  • pageIndicatorTintColor
  • currentPageIndicatorTintColor
  • backgroundStyle

등의 변수를 만들고 이사이 관계들을 정해주면 될 것 같다.

인스타 그램 스타일 페이지 인디케이터 알고리즘

이제 인스타 그램의 페이지 컨트롤이 어떤 알고리즘으로 작동하는지 보자.
(생각보다 너무 복잡해서 애먹었다.)

내가 분석한 알고리즘은 이렇다.

  • 3가지 점의 사이즈가 존재한다.
  • numberOfPages가 5 이하일 경우에는 큰점 사이즈로 모든 점을 노출한다.
  • numberOfPages가 5 초과일 경우에는 아래 알고리즘에 따라서 점의 개수를 노출한다.

5이상일 때 점의 사이즈 및 노출 조건

  • 큰점은 항상 3개 노출된다.
  • 선택된 점은 항상 큰점 이여야 한다.
  • 큰점 3개 양 옆에는 작은 점이 올 수 없다. (중간점 아니면 비어있음)
  • 최대 노출 될 수 있는 점의 개수는 7개 이다.

그림을 보면서 생각해보면,
현재 선택된 인덱스가 상승하고 있을 때는,
2번 인덱스에 올 때까지 점 크기의 변화는 없다.

그런데, 3번인덱스로 넘어가려는 순간 선택된 점은 항상 큰점 이여야 한다 가 어겨지면서 점의 사이즈를 바꿔야 한다.

그림을 보면 쉽게 이해 할 수 있을 것이다.

그림 처럼 7개의 점이 있다고 가정할 때,
각 각 7개의 인덱스에 해당하는 점을 숨겨야 하나? 보여줘야 한다면 어떤 사이즈의 점으로 노출되지?

라는 물음에 대답을 하는 방식으로 풀이를 했다.

// 각 사이즈에 따라 배열을 만들어 지금 그 사이즈 여야 하는 인덱스를 모아 놓는다. 
private var fullScaleIndex: Array<Int> = []
private var mediumScaleIndex: Array<Int> = []
private var smallScaleIndex: Array<Int> = []

private func calculateDotSize() {
    // 선택될 점이 이미 큰점이라면, 점들의 크기에 변화가 없다.
    guard !fullScaleIndex.contains(currentPage) else { return }

    // 사용자가 페이지를 오른쪽으로 넘기고 있는지 왼쪽으로 넘기고 있는지를 계산한다.
    let increaseWay = currentPage > (fullScaleIndex.max() ?? 0)
    let pivotValue = increaseWay ? 1 : -1

    // 그에 따라서 점들을 배치해 준다. 
    let updatedFullScaleIndex = [currentPage - 2 * pivotValue, currentPage - pivotValue, currentPage]
    let updatedMediumScaleIndex = [updatedFullScaleIndex.min()! - 1, updatedFullScaleIndex.max()! + 1]
    let updatedSmallScaleIndex = [updatedMediumScaleIndex.min()! - 1, updatedMediumScaleIndex.max()! + 1]

    // 배치한 점들 가운데, 0...last 를 벗어 난 것들을 filter 로 걸러준다.
    fullScaleIndex = updatedFullScaleIndex
    mediumScaleIndex = updatedMediumScaleIndex.filter { $0 >= 0 && $0 < numberOfPages }
    smallScaleIndex = updatedSmallScaleIndex.filter { $0 >= 0 && $0 < numberOfPages }
}

전체 코드

import Foundation
import UIKit
import SnapKit

class CustomPageControl: UIControl {

    var numberOfPages: Int = 1 {
        didSet {
            setupPageIndicators()
        }
    }

    var currentPage: Int = 0 {
        didSet {
            if oldValue == currentPage {
                return
            }

            if hideForSinglePage == true && numberOfPages == 1 {
            } else {
                updatePageIndicators()
            }
        }
    }

    var indicatorColor: UIColor = .black.withAlphaComponent(0.2)
    var currentIndicatorColor: UIColor = .black
    var pageIndicatorBigSize: CGFloat = 4.0
    var pageIndicatorMediumSize: CGFloat = 3.0
    var pageIndicatorSmallSize: CGFloat = 2.0
    var pageIndicatorSpacing: CGFloat = 4.0
    var hideForSinglePage: Bool = true
    var isContainerBackgroundOn: Bool = false
    var containerBackgroundColor: UIColor = .black.withAlphaComponent(0.2)

    private var containerView: UIView = UIView()
    private var dotStackView: UIStackView = UIStackView()

    private var fullScaleIndex: Array<Int> = []
    private var mediumScaleIndex: Array<Int> = []
    private var smallScaleIndex: Array<Int> = []
    private var pageIndicatorViews: [UIView] = []

    override func layoutSubviews() {
        super.layoutSubviews()

        if hideForSinglePage == true && numberOfPages == 1 {
            removeAllIndex()
        } else if numberOfPages < 6 {
            removeAllIndex()
            fullScaleIndex.append(contentsOf: 0..<numberOfPages)
        } else {
//            removeAllIndex()
            calculateDotSize()
        }
        updatePageIndicators()
    }

    private func setupPageIndicators() {
        pageIndicatorViews.forEach { $0.removeFromSuperview() }
        pageIndicatorViews.removeAll()

        containerView.backgroundColor = isContainerBackgroundOn ? containerBackgroundColor : .clear

        addSubview(containerView)

        containerView.snp.makeConstraints { make in
            make.height.equalTo(20)
            make.center.equalToSuperview()
        }
        containerView.layer.cornerRadius = 10

        dotStackView.spacing = 4
        dotStackView.alignment = .center
        dotStackView.axis = .horizontal
        dotStackView.backgroundColor = .clear

        containerView.addSubview(dotStackView)
        dotStackView.snp.makeConstraints { make in
            make.edges.equalToSuperview().inset(8)
        }

        for _ in 0..<numberOfPages {
            let pageIndicatorView = UIView()
            pageIndicatorView.snp.makeConstraints { make in
                make.width.height.equalTo(pageIndicatorBigSize)
            }
            pageIndicatorView.layer.cornerRadius = pageIndicatorBigSize / 2.0
            dotStackView.addArrangedSubview(pageIndicatorView)
            pageIndicatorView.backgroundColor = indicatorColor
            pageIndicatorViews.append(pageIndicatorView)
        }
        dotStackView.snp.remakeConstraints { make in
            make.edges.equalToSuperview().inset(8)
        }
    }

    private func updatePageIndicators() {
        guard !pageIndicatorViews.isEmpty else { return }

        if numberOfPages > 5 {
            calculateDotSize()
        }

        for (index, pageIndicatorView) in dotStackView.arrangedSubviews.enumerated() {
            let isSelected = index == currentPage
            pageIndicatorView.backgroundColor = isSelected ? currentIndicatorColor : indicatorColor

            if let dotSize = getDotSize(index: index) {
                pageIndicatorView.isHidden = false

                if pageIndicatorView.frame.width != dotSize {
                    pageIndicatorView.snp.remakeConstraints { make in
                        make.width.height.equalTo(dotSize)
                    }
                }
            } else {
                pageIndicatorView.isHidden = true
            }
        }

        UIView.animate(withDuration: 0.1) { [weak self] in
            guard let self else { return }
            self.dotStackView.layoutIfNeeded()
        }
    }

    private func removeAllIndex() {
        fullScaleIndex.removeAll()
        mediumScaleIndex.removeAll()
        smallScaleIndex.removeAll()
    }

    private func calculateDotSize() {
        guard !fullScaleIndex.contains(currentPage) else { return }

        let increaseWay = currentPage > (fullScaleIndex.max() ?? 0)
        let pivotValue = increaseWay ? 1 : -1

        let updatedFullScaleIndex = [currentPage - 2 * pivotValue, currentPage - pivotValue, currentPage]
        let updatedMediumScaleIndex = [updatedFullScaleIndex.min()! - 1, updatedFullScaleIndex.max()! + 1]
        let updatedSmallScaleIndex = [updatedMediumScaleIndex.min()! - 1, updatedMediumScaleIndex.max()! + 1]

        fullScaleIndex = updatedFullScaleIndex
        mediumScaleIndex = updatedMediumScaleIndex.filter { $0 >= 0 && $0 < numberOfPages }
        smallScaleIndex = updatedSmallScaleIndex.filter { $0 >= 0 && $0 < numberOfPages }
    }

    private func getDotSize(index: Int) -> CGFloat? {
        var dotSize: CGFloat? = nil

        if fullScaleIndex.contains(index) {
            dotSize = pageIndicatorBigSize
        } else if mediumScaleIndex.contains(index) {
            dotSize = pageIndicatorMediumSize
        } else if smallScaleIndex.contains(index) {
            dotSize = pageIndicatorSmallSize
        }

        return dotSize
    }
}

완성본

짠!

'🍎 iOS > 🍏 UIKit' 카테고리의 다른 글

[UIKit] App life cycle 이란?  (0) 2023.01.27
[iOS] 키보드야 텍스트 가리지마  (0) 2023.01.06
[iOS]사용자의 폰트 사이즈 정보에 따라 UI 바꿔주기  (0) 2023.01.06
[iOS] delegate 구현하기  (0) 2023.01.06
[iOS] Notification Center 구현하기  (0) 2023.01.06

    티스토리툴바