인스타그램 스타일의 페이지 컨트롤을 만들려고 했다.
UIPageControl
처음엔 UIPageControl
을 이용해서 만들려고 했지만,
- 보여 줄 수 있는 점의 개수와 종류를 지정할 수 없음
- 점의 크기를 지정할 수 없음
위의 두가지 이유로 UIPageControl 을 사용할 수 없었다.
UIControl
애초에 UIPageControl 이 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 |