Skip to content

Commit

Permalink
Implement the page-control package
Browse files Browse the repository at this point in the history
  • Loading branch information
ns-vasilev committed Jan 7, 2025
1 parent 6b39649 commit 8007941
Show file tree
Hide file tree
Showing 8 changed files with 468 additions and 6 deletions.
6 changes: 1 addition & 5 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ disabled_rules:
- trailing_comma
- todo
- opening_brace
- identifier_name

opt_in_rules: # some rules are only opt-in
- anyobject_protocol
Expand Down Expand Up @@ -94,11 +95,6 @@ opt_in_rules: # some rules are only opt-in
force_cast: warning
force_try: warning

identifier_name:
excluded:
- id
- URL

analyzer_rules:
- unused_import
- unused_declaration
Expand Down
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import PackageDescription

let package = Package(
name: "page-control",
platforms: [.iOS(.v15)],
products: [
.library(name: "PageControl", targets: ["PageControl"]),
],
Expand Down
132 changes: 132 additions & 0 deletions Sources/PageControl/Classes/Drawers/Implementations/BaseDrawer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//
// page-control
// Copyright © 2025 Space Code. All rights reserved.
//

import UIKit

public class BaseDrawer: IDrawer {
// MARK: Properties

/// The size of the item, defined by its width and height.
public var size: CGSize
/// The index of the currently selected item, represented as a fractional value to support animations or transitions.
public var currentItem: CGFloat
/// The total number of items (pages) in the drawer.
public var numberOfPages: Int

/// The space between individual items in the drawer.
let space: CGFloat
/// The default color of the items, used for unselected items.
let itemColor: UIColor
/// The color of the currently selected item, often more prominent to indicate focus.
let selectedItemColor: UIColor
/// The corner radius of the items, used to give them rounded edges.
let radius: CGFloat

public var contentSize: CGSize {
CGSize(
width: CGFloat(numberOfPages - 1) * size.width + CGFloat(numberOfPages - 1) * space,
height: size.height + 16.0
)
}

// MARK: Initialization

/// Initializes a new instance of `BaseDrawer` with customizable properties.
/// - Parameters:
/// - currentItem: The initial index of the selected item. Defaults to `0.0`.
/// - numberOfPages: The total number of items (pages). Defaults to `5`.
/// - space: The space between individual items in the drawer. Defaults to `4.0`.
/// - width: The width of each item. Defaults to `16.0`.
/// - height: The height of each item. Defaults to `3.0`.
/// - itemColor: The default color for unselected items. Defaults to `UIColor.lightGray`.
/// - selectedItemColor: The color for the selected item. Defaults to a semi-transparent blue.
/// - radius: The corner radius of the items for rounded edges. Defaults to `2`.
public init(
currentItem: CGFloat = .zero,
numberOfPages: Int = .zero,
space: CGFloat = 4.0,
width: CGFloat = 16.0,
height: CGFloat = 3.0,
itemColor: UIColor = .lightGray,
selectedItemColor: UIColor = .blue.withAlphaComponent(0.8),
radius: CGFloat = 2
) {
size = CGSize(width: width, height: height)
self.currentItem = currentItem
self.numberOfPages = numberOfPages
self.space = space
self.itemColor = itemColor
self.selectedItemColor = selectedItemColor
self.radius = radius
}

// MARK: IDrawer

public func draw(_: CGRect) {}

// MARK: Internal

/// Calculates the horizontal center position for an item in a layout.
///
/// - Parameters:
/// - rect: The bounding rectangle of the container.
/// - position: The item's position index (e.g., 0 for the first item).
/// - size: The width of the item.
/// - space: The space between items.
/// - numberOfPages: The total number of items (pages) in the layout.
///
/// - Returns: The `x` coordinate for the center of the item.
func centerX(
_ rect: CGRect,
position: CGFloat,
size: CGFloat,
space: CGFloat,
numberOfPages: Int
) -> CGFloat {
let dotPosition = (position * (size + space))
let midX = rect.size.width / 2.0
let midXWithSpaces = ((CGFloat(numberOfPages) * (size + (space - 1))) / 2.0)

return dotPosition - midXWithSpaces + midX
}

/// Calculates the vertical center position for an item in a layout.
///
/// - Parameters:
/// - rect: The bounding rectangle of the container.
/// - size: The height of the item.
///
/// - Returns: The `y` coordinate for the center of the item.
func centerY(_ rect: CGRect, size: CGFloat) -> CGFloat {
let midY = rect.size.height / 2.0
let midDotY = size / 2.0
let centerY = midY - midDotY

return centerY
}

/// Draws a rounded rectangular item with optional border and fill color.
///
/// - Parameters:
/// - rect: The rectangle defining the item's position and size.
/// - radius: The corner radius for the item's rounded edges.
/// - color: The fill color for the item.
/// - borderWidth: The width of the item's border. Defaults to `0`.
/// - borderColor: The color of the item's border. Defaults to `.clear`.
func drawItem(
_ rect: CGRect,
radius: CGFloat,
color: UIColor,
borderWidth: CGFloat = .zero,
borderColor: UIColor = .clear
) {
let path = UIBezierPath(roundedRect: rect, cornerRadius: radius)
path.lineWidth = borderWidth
borderColor.setStroke()
path.stroke()
color.setFill()
path.fill()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
//
// page-control
// Copyright © 2025 Space Code. All rights reserved.
//

import UIKit

// MARK: - ExtendedLineDrawer

public final class ExtendedLineDrawer: BaseDrawer {
// MARK: Override

override public func draw(_ rect: CGRect) {
drawIndicators(rect)
drawCurrentItem(rect)
}

override public var contentSize: CGSize {
CGSize(
width: selectedItemWidth + CGFloat(numberOfPages - 1) * size.width + CGFloat(numberOfPages - 1) * space,
height: size.height + .extraSpace
)
}

// MARK: Private

// swiftlint:disable:next function_body_length
private func drawIndicators(_ rect: CGRect) {
let step = (space + size.width)

for index in 0 ... numberOfPages {
if index != Int(currentItem + 1), index != Int(currentItem) {
var newX: CGFloat = .zero
var newY: CGFloat = .zero
var newHeight: CGFloat = .zero
var newWidth: CGFloat = .zero

let progress = currentItem - floor(currentItem)

var itemColor = itemColor

if index == Int(currentItem + 2) {
itemColor = (self.itemColor * Double(1 - progress)) + (selectedItemColor * Double(progress))

let centerY = centerY(rect, size: size.height)

let currentProgress = currentItem - floor(currentItem)
let currentPosition = floor(currentItem + 2) - currentProgress

let x = centerX(
rect,
position: currentPosition,
size: size.width,
space: space,
numberOfPages: numberOfPages + 1
)

let ratio = 1 - currentProgress
let scale = step - (ratio * step)

newX = rect.origin.x + x
newY = rect.origin.y + centerY
newWidth = size.width + scale
newHeight = size.height
} else {
let centerY = centerY(rect, size: size.height)

let x = centerX(
rect,
position: CGFloat(index),
size: size.width,
space: space,
numberOfPages: numberOfPages + 1
)

newX = rect.origin.x + x
newY = rect.origin.y + centerY
newWidth = size.width
newHeight = size.height
}

drawItem(
CGRect(
x: newX,
y: newY,
width: newWidth,
height: newHeight
),
radius: radius,
color: itemColor
)
}
}
}

private func drawCurrentItem(_ rect: CGRect) {
let progress = currentItem - floor(currentItem)
let color = (itemColor * Double(progress)) + (selectedItemColor * Double(1 - progress))

if currentItem >= 0 {
let step = (space + size.width)
let centerY = centerY(rect, size: size.height)
let position = floor(currentItem)

let centerX = centerX(
rect,
position: position,
size: size.width,
space: space,
numberOfPages: numberOfPages + 1
)

let rect = CGRect(
x: rect.origin.x + centerX,
y: rect.origin.y + centerY,
width: selectedItemWidth,
height: size.height
)

drawItem(rect, radius: radius, color: color)
}
}

private var selectedItemWidth: CGFloat {
let step = (space + size.width)
let currentProgress = currentItem - floor(currentItem)
let ratio = 1 - currentProgress
let desiredWidth = size.width + ratio * step

return desiredWidth
}
}

// MARK: Constants

private extension CGFloat {
static let extraSpace: CGFloat = 16.0
}
30 changes: 30 additions & 0 deletions Sources/PageControl/Classes/Drawers/Interfaces/IDrawer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// page-control
// Copyright © 2025 Space Code. All rights reserved.
//

import UIKit

/// A protocol that defines the behavior for drawing a custom UI component, such as a pager or a carousel.
public protocol IDrawer {
/// The index or position of the current item being displayed.
/// This property determines which item is currently in focus.
var currentItem: CGFloat { get set }

/// The size of each item in the drawer.
/// This could represent the width or height, depending on the layout.
var size: CGSize { get set }

/// The total number of pages or items in the drawer.
/// Used to calculate the range of drawable items or manage pagination logic.
var numberOfPages: Int { get set }

/// The content size of the element.
var contentSize: CGSize { get }

/// A method responsible for drawing the content within the specified rectangle.
///
/// - Parameter rect: The area in which the content should be drawn.
/// This is typically provided by the rendering system (e.g., a `UIView` or `CALayer`).
func draw(_ rect: CGRect)
}
58 changes: 58 additions & 0 deletions Sources/PageControl/Classes/Helpers/Extensions/UIColor+.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// page-control
// Copyright © 2025 Space Code. All rights reserved.
//

import UIKit

extension UIColor {
/// Adds two `UIColor` objects together.
///
/// - Parameters:
/// - color1: The first `UIColor`.
/// - color2: The second `UIColor`.
///
/// - Returns: A new `UIColor` representing the sum of the two colors.
/// The resulting color's components are clamped to a maximum of 1.0.
/// If either color cannot be decomposed into RGBA components, `.clear` is returned.
static func + (color1: UIColor, color2: UIColor) -> UIColor {
var (r1, g1, b1, a1) = (CGFloat(0), CGFloat(0), CGFloat(0), CGFloat(0))
var (r2, g2, b2, a2) = (CGFloat(0), CGFloat(0), CGFloat(0), CGFloat(0))

guard color1.getRed(&r1, green: &g1, blue: &b1, alpha: &a1),
color2.getRed(&r2, green: &g2, blue: &b2, alpha: &a2)
else {
return .clear
}

return UIColor(
red: min(r1 + r2, 1.0),
green: min(g1 + g2, 1.0),
blue: min(b1 + b2, 1.0),
alpha: (a1 + a2) / 2
)
}

/// Multiplies a `UIColor`'s RGB components by a scalar multiplier.
///
/// - Parameters:
/// - color: The `UIColor` to modify.
/// - multiplier: The scalar multiplier applied to the RGB components. Values are clamped between 0 and 1.
///
/// - Returns: A new `UIColor` with modified RGB components. The alpha remains unchanged.
/// If the color cannot be decomposed into RGBA components, `.clear` is returned.
static func * (color: UIColor, multiplier: CGFloat) -> UIColor {
var (r, g, b, a) = (CGFloat(0), CGFloat(0), CGFloat(0), CGFloat(0))

guard color.getRed(&r, green: &g, blue: &b, alpha: &a) else {
return .clear
}

return UIColor(
red: min(max(r * multiplier, 0), 1.0),
green: min(max(g * multiplier, 0), 1.0),
blue: min(max(b * multiplier, 0), 1.0),
alpha: a
)
}
}
Loading

0 comments on commit 8007941

Please sign in to comment.