diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8dc7e75 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,41 @@ +--- +name: "🐛 Bug Report" +about: Report a reproducible bug or regression. +title: 'Bug: ' +labels: 'bug' + +--- + + + +Application version: + +## Steps To Reproduce + +1. +2. + + + +Link to code example: + + + +## The current behavior + + +## The expected behavior \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..c32476c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,11 @@ +--- +name: 🛠 Feature request +about: If you have a feature request for the page-control, file it here. +labels: 'type: enhancement' +--- + +**Feature description** +Clearly and concisely describe the feature. + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE/bug_template.md b/.github/PULL_REQUEST_TEMPLATE/bug_template.md new file mode 100644 index 0000000..7d6a149 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/bug_template.md @@ -0,0 +1,9 @@ +## Bug description +Clearly and concisely describe the problem. + +## Solution description +Describe your code changes in detail for reviewers. Explain the technical solution you have provided and how it fixes the issue case. + +## Covered unit test cases +- [x] yes +- [x] no \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE/feature_template.md b/.github/PULL_REQUEST_TEMPLATE/feature_template.md new file mode 100644 index 0000000..ab3978b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/feature_template.md @@ -0,0 +1,12 @@ +## Feature description +Clearly and concisely describe the feature. + +## Solution description +Describe your code changes in detail for reviewers. + +## Areas affected and ensured +List out the areas affected by your code changes. + +## Covered unit test cases +- [x] yes +- [x] no \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..92a240d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + open-pull-requests-limit: 10 + schedule: + interval: daily + time: '07:00' + timezone: Europe/Berlin + + assignees: + - ns_vasilev + reviewers: + - ns_vasilev + + + - package-ecosystem: swift + directory: / + open-pull-requests-limit: 10 + schedule: + interval: daily + time: '07:00' + timezone: Europe/Berlin + + assignees: + - ns_vasilev + reviewers: + - ns_vasilev + \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c3e04db --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: "page-control" + +on: + push: + branches: + - main + - dev + pull_request: + paths: + - '.swiftlint.yml' + - ".github/workflows/**" + - "Package.swift" + - "Source/**" + - "Tests/**" + +jobs: + SwiftLint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: GitHub Action for SwiftLint + uses: norio-nomura/action-swiftlint@3.2.1 + with: + args: --strict + env: + DIFF_BASE: ${{ github.base_ref }} + iOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - destination: "OS=18.1,name=iPhone 16 Pro" + name: "iOS 18.1" + xcode: "Xcode_16.1" + runsOn: macOS-14 + - destination: "OS=18.0,name=iPhone 16 Pro" + name: "iOS 18.0" + xcode: "Xcode_16.0" + runsOn: macOS-14 + steps: + - uses: actions/checkout@v4 + - name: ${{ matrix.name }} + run: xcodebuild test -scheme "page-control" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 + + discover-typos: + name: Discover Typos + runs-on: macOS-13 + env: + DEVELOPER_DIR: /Applications/Xcode_14.1.app/Contents/Developer + steps: + - uses: actions/checkout@v4 + - name: Discover typos + run: | + export PATH="$PATH:/Library/Frameworks/Python.framework/Versions/3.11/bin" + python3 -m pip install --upgrade pip + python3 -m pip install codespell + codespell --ignore-words-list="hart,inout,msdos,sur" --skip="./.build/*,./.git/*" \ No newline at end of file diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml new file mode 100644 index 0000000..55a4794 --- /dev/null +++ b/.github/workflows/danger.yml @@ -0,0 +1,31 @@ +name: Danger + +on: + pull_request: + types: [synchronize, opened, reopened, labeled, unlabeled, edited] + +env: + LC_CTYPE: en_US.UTF-8 + LANG: en_US.UTF-8 + +jobs: + run-danger: + runs-on: ubuntu-latest + steps: + - name: ruby setup + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.4 + bundler-cache: true + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup gems + run: | + gem install bundler + bundle install --clean --path vendor/bundle + - name: danger + env: + + DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }} + + run: bundle exec danger --verbose \ No newline at end of file diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..d40966e --- /dev/null +++ b/.swiftformat @@ -0,0 +1,64 @@ +# Stream rules + +--swiftversion 5.3 + +# Use 'swiftformat --options' to list all of the possible options + +--header "\npage-control\nCopyright © {created.year} Space Code. All rights reserved.\n//" + +--enable blankLinesBetweenScopes +--enable blankLinesAtStartOfScope +--enable blankLinesAtEndOfScope +--enable blankLinesAroundMark +--enable anyObjectProtocol +--enable consecutiveBlankLines +--enable consecutiveSpaces +--enable duplicateImports +--enable elseOnSameLine +--enable emptyBraces +--enable initCoderUnavailable +--enable leadingDelimiters +--enable numberFormatting +--enable preferKeyPath +--enable redundantBreak +--enable redundantExtensionACL +--enable redundantFileprivate +--enable redundantGet +--enable redundantInit +--enable redundantLet +--enable redundantLetError +--enable redundantNilInit +--enable redundantObjc +--enable redundantParens +--enable redundantPattern +--enable redundantRawValues +--enable redundantReturn +--enable redundantSelf +--enable redundantVoidReturnType +--enable semicolons +--enable sortImports +--enable sortSwitchCases +--enable spaceAroundBraces +--enable spaceAroundBrackets +--enable spaceAroundComments +--enable spaceAroundGenerics +--enable spaceAroundOperators +--enable spaceInsideBraces +--enable spaceInsideBrackets +--enable spaceInsideComments +--enable spaceInsideGenerics +--enable spaceInsideParens +--enable strongOutlets +--enable strongifiedSelf +--enable todos +--enable trailingClosures +--enable unusedArguments +--enable void +--enable markTypes +--enable isEmpty + +# format options + +--wraparguments before-first +--wrapcollections before-first +--maxwidth 140 \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..757c73a --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,131 @@ +excluded: + - Tests + - Package.swift + - .build + +# Rules + +disabled_rules: + - trailing_comma + - todo + - opening_brace + - identifier_name + +opt_in_rules: # some rules are only opt-in + - anyobject_protocol + - array_init + - attributes + - closure_body_length + - closure_end_indentation + - closure_spacing + - collection_alignment + - conditional_returns_on_newline + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - convenience_type + - discouraged_object_literal + - discouraged_optional_boolean + - empty_collection_literal + - empty_count + - empty_string + - empty_xctest_method + - enum_case_associated_values_count + - explicit_init + - fallthrough + - fatal_error_message + - file_name + - file_types_order + - first_where + - flatmap_over_map_reduce + - force_unwrapping + - ibinspectable_in_extension + - identical_operands + - implicit_return + - inert_defer + - joined_default_parameter + - last_where + - legacy_multiple + - legacy_random + - literal_expression_end_indentation + - lower_acl_than_parent + - multiline_arguments + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - multiline_parameters_brackets + - no_space_in_method_call + - operator_usage_whitespace + - optional_enum_case_matching + - orphaned_doc_comment + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - prefixed_toplevel_constant + - private_action + - prohibited_super_call + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - reduce_into + - redundant_nil_coalescing + - redundant_objc_attribute + - redundant_type_annotation + - required_enum_case + - single_test_class + - sorted_first_last + - sorted_imports + - static_operator + - strict_fileprivate + - switch_case_on_newline + - toggle_bool + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - xct_specific_matcher + - yoda_condition + +force_cast: warning +force_try: warning + +analyzer_rules: + - unused_import + - unused_declaration + +line_length: + warning: 130 + error: 200 + +type_body_length: + warning: 300 + error: 400 + +file_length: + warning: 500 + error: 1200 + +function_body_length: + warning: 30 + error: 50 + +large_tuple: + error: 3 + +nesting: + type_level: + warning: 2 + statement_level: + warning: 10 + + +type_name: + max_length: + warning: 40 + error: 50 \ No newline at end of file diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5e14798 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Change Log +All notable changes to this project will be documented in this file. + +## [1.0.0](https://github.com/space-code/page-control/releases/tag/1.0.0) +Released on 2025-01-07. + +#### Added +- Initial release of PageControl. + - Added by [Nikita Vasilev](https://github.com/ns-vasilev). \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..56c1661 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting one of the project maintainers https://github.com/orgs/space-code/people. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1c1705b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing Guidelines + +This document contains information and guidelines about contributing to this project. +Please read it before you start participating. + +**Topics** + +* [Reporting Issues](#reporting-issues) +* [Submitting Pull Requests](#submitting-pull-requests) +* [Developers Certificate of Origin](#developers-certificate-of-origin) +* [Code of Conduct](#code-of-conduct) + +## Reporting Issues + +A great way to contribute to the project is to send a detailed issue when you encounter a problem. We always appreciate a well-written, thorough bug report. + +Check that the project issues database doesn't already include that problem or suggestion before submitting an issue. If you find a match, feel free to vote for the issue by adding a reaction. Doing this helps prioritize the most common problems and requests. + +When reporting issues, please fill out our issue template. The information the template asks for will help us review and fix your issue faster. + +## Submitting Pull Requests + +You can contribute by fixing bugs or adding new features. For larger code changes, we recommend first discussing your ideas on our [GitHub Discussions](https://github.com/space-code/page-control/discussions). When submitting a pull request, please add relevant tests and ensure your changes don't break any existing tests. + +## Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +- (a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +- (b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +- (c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +- (d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. + +## Code of Conduct + +The Code of Conduct governs how we behave in public or in private +whenever the project will be judged by our actions. +We expect it to be honored by everyone who contributes to this project. + +See [CODE_OF_CONDUCT.md](https://github.com/space-code/page-control/blob/master/CODE_OF_CONDUCT.md) for details. + +--- + +*Some of the ideas and wording for the statements above were based on work by the [Docker](https://github.com/docker/docker/blob/master/CONTRIBUTING.md) and [Linux](https://elinux.org/Developer_Certificate_Of_Origin) communities. \ No newline at end of file diff --git a/Dangerfile b/Dangerfile new file mode 100644 index 0000000..b266982 --- /dev/null +++ b/Dangerfile @@ -0,0 +1 @@ +danger.import_dangerfile(github: 'space-code/dangerfile') \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..20dff64 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem 'danger' \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..de1897f --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +all: bootstrap + +bootstrap: hook + mint bootstrap + +hook: + ln -sf ../../hooks/pre-commit .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + +mint: + mint bootstrap + +lint: + mint run swiftlint + +fmt: + mint run swiftformat Sources Tests + +.PHONY: all bootstrap hook mint lint fmt diff --git a/Mintfile b/Mintfile new file mode 100644 index 0000000..e2cdefa --- /dev/null +++ b/Mintfile @@ -0,0 +1,2 @@ +nicklockwood/SwiftFormat@0.52.7 +realm/SwiftLint@0.53.0 \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..e93ac95 --- /dev/null +++ b/Package.swift @@ -0,0 +1,16 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "page-control", + platforms: [.iOS(.v15)], + products: [ + .library(name: "PageControl", targets: ["PageControl"]), + ], + targets: [ + .target(name: "PageControl"), + .testTarget(name: "PageControlTests", dependencies: ["PageControl"]), + ] +) diff --git a/README.md b/README.md index 82d0d4c..8bc31ea 100644 --- a/README.md +++ b/README.md @@ -1 +1,88 @@ -# page-control \ No newline at end of file +

page-control

+ +

+License +Swift Compatibility +Platform Compatibility +CI +

+ +## Description +`page-control` is a highly customizable page control. + +- [Usage](#usage) +- [Requirements](#requirements) +- [Installation](#installation) +- [Communication](#communication) +- [Contributing](#contributing) +- [Author](#author) +- [License](#license) + +## Usage + +```swift +import PageControl + +let pageControl = PageControl() + +pageControl.numberOfPages = 5 +pageControl.drawer = ExtendedLineDrawer( + itemColor: .lightGray, + selectedItemColor: .blue +) +``` + +The custom drawer can be implemented, such as: + +```swift +import PageControl + +final class MyCustomDrawer: BaseDrawer { + override public func draw(_ rect: CGRect) { + // Custom implementation for drawing content in the provided rectangle. + } + + override public var contentSize: CGSize { + // Custom implementation for calculating the content size. + } +} +``` + +## Requirements + +- iOS 14.0+ +- Xcode 16.0 +- Swift 6.0 + +## Installation +### Swift Package Manager + +The [Swift Package Manager](https://swift.org/package-manager/) is a tool for automating the distribution of Swift code and is integrated into the `swift` compiler. It is in early development, but `page-control` does support its use on supported platforms. + +Once you have your Swift package set up, adding `page-control` as a dependency is as easy as adding it to the `dependencies` value of your `Package.swift`. + +```swift +dependencies: [ + .package(url: "https://github.com/space-code/page-control.git", .upToNextMajor(from: "1.0.0")) +] +``` + +## Communication +- If you **found a bug**, open an issue. +- If you **have a feature request**, open an issue. +- If you **want to contribute**, submit a pull request. + +## Contributing +Bootstrapping development environment + +``` +make bootstrap +``` + +Please feel free to help out with this project! If you see something that could be made better or want a new feature, open up an issue or send a Pull Request! + +## Author +Nikita Vasilev, nv3212@gmail.com + +## License +page-control is available under the MIT license. See the LICENSE file for more info. \ No newline at end of file diff --git a/SEQURITY.md b/SEQURITY.md new file mode 100644 index 0000000..20dffca --- /dev/null +++ b/SEQURITY.md @@ -0,0 +1,7 @@ +# Reporting Security Vulnerabilities + +This software is built with security and data privacy in mind to ensure your data is safe. We are grateful for security researchers and users reporting a vulnerability to us, first. To ensure that your request is handled in a timely manner and non-disclosure of vulnerabilities can be assured, please follow the below guideline. + +**Please do not report security vulnerabilities directly on GitHub. GitHub Issues can be publicly seen and therefore would result in a direct disclosure.** + +* Please address questions about data privacy, security concepts, and other media requests to the nv3212@gmail.com mailbox. \ No newline at end of file diff --git a/Sources/PageControl/Classes/Drawers/Implementations/BaseDrawer.swift b/Sources/PageControl/Classes/Drawers/Implementations/BaseDrawer.swift new file mode 100644 index 0000000..eda03a5 --- /dev/null +++ b/Sources/PageControl/Classes/Drawers/Implementations/BaseDrawer.swift @@ -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() + } +} diff --git a/Sources/PageControl/Classes/Drawers/Implementations/ExtendedLineDrawer.swift b/Sources/PageControl/Classes/Drawers/Implementations/ExtendedLineDrawer.swift new file mode 100644 index 0000000..4c23433 --- /dev/null +++ b/Sources/PageControl/Classes/Drawers/Implementations/ExtendedLineDrawer.swift @@ -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 +} diff --git a/Sources/PageControl/Classes/Drawers/Interfaces/IDrawer.swift b/Sources/PageControl/Classes/Drawers/Interfaces/IDrawer.swift new file mode 100644 index 0000000..cfa03d8 --- /dev/null +++ b/Sources/PageControl/Classes/Drawers/Interfaces/IDrawer.swift @@ -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) +} diff --git a/Sources/PageControl/Classes/Helpers/Extensions/UIColor+.swift b/Sources/PageControl/Classes/Helpers/Extensions/UIColor+.swift new file mode 100644 index 0000000..851ac3e --- /dev/null +++ b/Sources/PageControl/Classes/Helpers/Extensions/UIColor+.swift @@ -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 + ) + } +} diff --git a/Sources/PageControl/Classes/PageControl.swift b/Sources/PageControl/Classes/PageControl.swift new file mode 100644 index 0000000..c9df5f0 --- /dev/null +++ b/Sources/PageControl/Classes/PageControl.swift @@ -0,0 +1,113 @@ +// +// page-control +// Copyright © 2025 Space Code. All rights reserved. +// + +import UIKit + +// MARK: - PageControl + +public final class PageControl: UIView { + // MARK: Properties + + private var startTime: TimeInterval = .zero + private var nextCurrentItem: CGFloat = .zero + private var previousCurrentItem: CGFloat = .zero + private var displayLink: CADisplayLink? + + public var drawer: IDrawer = ExtendedLineDrawer() + + public var numberOfPages: Int { + get { drawer.numberOfPages } + set { + setNeedsDisplay() + drawer.numberOfPages = newValue + } + } + + // MARK: Initialization + + override public init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + // MARK: Override + + override public var intrinsicContentSize: CGSize { + sizeThatFits(.zero) + } + + override public func sizeThatFits(_: CGSize) -> CGSize { + drawer.contentSize + } + + override public func draw(_ rect: CGRect) { + drawer.draw(rect) + } + + // MARK: Public + + public func set(pageOffset: CGFloat) { + drawer.currentItem = pageOffset + setNeedsDisplay() + } + + public func set(page index: Int) { + if nextCurrentItem != CGFloat(index) { + previousCurrentItem = round(drawer.currentItem) + nextCurrentItem = CGFloat(index) + runDisplayLink() + } + } + + // MARK: Private + + private func setupUI() { + backgroundColor = .clear + } + + private func runDisplayLink() { + stopDisplayLink() + + startTime = Date.timeIntervalSinceReferenceDate + + displayLink = CADisplayLink(target: self, selector: #selector(displayLinkDidFire(_:))) + displayLink?.add(to: .current, forMode: .common) + } + + private func stopDisplayLink() { + displayLink?.invalidate() + displayLink = nil + } + + // MARK: Actions + + @objc + private func displayLinkDidFire(_: CADisplayLink) { + var elapsed = Date.timeIntervalSinceReferenceDate - startTime + + if elapsed > .animationDuration { + stopDisplayLink() + elapsed = .animationDuration + } + + let progress = CGFloat(elapsed / .animationDuration) + let sign = nextCurrentItem - previousCurrentItem + + drawer.currentItem = CGFloat(progress * sign + previousCurrentItem) + + setNeedsDisplay() + } +} + +// MARK: Constants + +private extension Double { + static let animationDuration = 0.2 +} diff --git a/Tests/PageControlTests/PageControlTests.swift b/Tests/PageControlTests/PageControlTests.swift new file mode 100644 index 0000000..058028c --- /dev/null +++ b/Tests/PageControlTests/PageControlTests.swift @@ -0,0 +1,6 @@ +// +// page-control +// Copyright © 2025 Space Code. All rights reserved. +// + +import XCTest diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100755 index 0000000..956fdcb --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,38 @@ +#!/bin/bash +git diff --diff-filter=d --staged --name-only | grep -e '\.swift$' | while read line; do + if [[ $line == *"/Generated"* ]]; then + echo "IGNORING GENERATED FILE: " "$line"; + else + mint run swiftformat swiftformat "${line}"; + git add "$line"; + fi +done + +LINT=$(which mint) +if [[ -e "${LINT}" ]]; then + # Export files in SCRIPT_INPUT_FILE_$count to lint against later + count=0 + while IFS= read -r file_path; do + export SCRIPT_INPUT_FILE_$count="$file_path" + count=$((count + 1)) + done < <(git diff --name-only --cached --diff-filter=d | grep ".swift$") + export SCRIPT_INPUT_FILE_COUNT=$count + + if [ "$count" -eq 0 ]; then + echo "No files to lint!" + exit 0 + fi + + echo "Found $count lintable files! Linting now.." + mint run swiftlint --use-script-input-files --strict --config .swiftlint.yml + RESULT=$? # swiftline exit value is number of errors + + if [ $RESULT -eq 0 ]; then + echo "🎉 Well done. No violation." + fi + exit $RESULT +else + echo "⚠️ WARNING: SwiftLint not found" + echo "⚠️ You might want to edit .git/hooks/pre-commit to locate your swiftlint" + exit 0 +fi \ No newline at end of file