diff --git a/Sources/General/CGSize+Extensions.swift b/Sources/General/CGSize+Extensions.swift new file mode 100644 index 0000000..3dc30a3 --- /dev/null +++ b/Sources/General/CGSize+Extensions.swift @@ -0,0 +1,17 @@ +// +// CGSize+Extensions.swift +// UtiliKit iOS +// +// Created by Nathan Chiu on 5/18/22. +// Copyright © 2022 Bottle Rocket Studios. All rights reserved. +// + +import UIKit + +extension CGSize { + + /// The size whose width and height are both one. + static var one: CGSize { + Self.init(width: 1, height: 1) + } +} diff --git a/Sources/General/Comparable+Extensions.swift b/Sources/General/Comparable+Extensions.swift new file mode 100644 index 0000000..a954186 --- /dev/null +++ b/Sources/General/Comparable+Extensions.swift @@ -0,0 +1,35 @@ +// +// Comparable+Extensions.swift +// UtiliKit iOS +// +// Created by Nathan Chiu on 5/19/22. +// Copyright © 2022 Bottle Rocket Studios. All rights reserved. +// + +import Foundation + +extension Comparable { + + /// Keeps the value within the specified `min` and `max` values + /// - Parameters: + /// - minValue: The minimum value to return + /// - maxValue: The maximum value to return + /// - Returns: The `minValue` if less than `minValue`, else `maxValue` if greater than `maxValue`, else the value itself + func clamped(min minValue: Self, max maxValue: Self) -> Self { + min(max(minValue, self), maxValue) + } + + /// Keeps the value within the specified `range` of values + /// - Parameter range: The range of values to return + /// - Returns: The `range.lowerBound` if less than the range, else `range.upperBound` if greater than the range, else the value itself + func clamped(in range: Range) -> Self { + clamped(min: range.lowerBound, max: range.upperBound) + } + + /// Keeps the value within the specified `range` of values + /// - Parameter range: The range of values to return + /// - Returns: The `range.lowerBound` if less than the range, else `range.upperBound` if greater than the range, else the value itself + func clamped(in range: ClosedRange) -> Self { + clamped(min: range.lowerBound, max: range.upperBound) + } +} diff --git a/Sources/General/UIScrollView+Extensions.swift b/Sources/General/UIScrollView+Extensions.swift new file mode 100644 index 0000000..d734dd8 --- /dev/null +++ b/Sources/General/UIScrollView+Extensions.swift @@ -0,0 +1,67 @@ +// +// UIScrollView+Extensions.swift +// UtiliKit iOS +// +// Created by Nathan Chiu on 5/18/22. +// Copyright © 2022 Bottle Rocket Studios. All rights reserved. +// + +import UIKit + +// MARK: - Scroll position +extension UIScrollView { + + /// Content offset including `adjustedContentInset` + var insetAdjustedContentOffset: CGPoint { + .init(x: contentOffset.x + adjustedContentInset.left, + y: contentOffset.y + adjustedContentInset.top) + } + + /// Content size including `adjustedContentInset` + var insetAdjustedContentSize: CGSize { + .init(width: contentSize.width + adjustedContentInset.left + adjustedContentInset.right, + height: contentSize.height + adjustedContentInset.top + adjustedContentInset.bottom) + } + + /// Percentage the user has scrolled + var scrollPercentage: (horizontal: CGFloat, vertical: CGFloat) { + var totalScrollableHeight = insetAdjustedContentSize.height - frame.height + if insetAdjustedContentSize.height <= frame.height { + totalScrollableHeight = frame.height + } + let verticalPercentage = insetAdjustedContentOffset.y / totalScrollableHeight + + var totalScrollableWidth = insetAdjustedContentSize.width - frame.width + if insetAdjustedContentSize.width <= frame.width { + totalScrollableWidth = frame.width + } + let horizontalPercentage = insetAdjustedContentOffset.x / totalScrollableWidth + + return (horizontal: horizontalPercentage.clamped(min: 0, max: 1), vertical: verticalPercentage.clamped(min: 0, max: 1)) + } + + /// `true` if the scroll view is scrolled to the top, else `false` + var isScrolledToTop: Bool { + guard insetAdjustedContentSize.height > frame.height else { + return insetAdjustedContentOffset.y <= 0 + } + return scrollPercentage.vertical <= .leastNormalMagnitude + } + + /// `true` if the scroll view is scrolled to the bottom, else `false` + var isScrolledToBottom: Bool { + guard insetAdjustedContentSize.height > frame.height else { + return insetAdjustedContentOffset.y > insetAdjustedContentSize.height - frame.height + } + + // Ideally this wouldn't be hard coded, but `1 - .leastNormalMagnitude` doesn't always recognize when the bottom is reached. This could potentially be a problem on a very tall contentSize + return 1.0 - scrollPercentage.vertical <= .leastNormalMagnitude + } + + /// This method scrolls the content view to the top. If already at the top, the method does nothing. + /// - Parameter animated: `true` if the scrolling should be animated, `false` if it should be immediate. + func scrollToTop(animated: Bool) { + scrollRectToVisible(.init(origin: .zero, size: .one), animated: animated) + delegate?.scrollViewDidScroll?(self) + } +} diff --git a/Tests/CGSizeTests.swift b/Tests/CGSizeTests.swift new file mode 100644 index 0000000..91f5b14 --- /dev/null +++ b/Tests/CGSizeTests.swift @@ -0,0 +1,18 @@ +// +// CGSizeTests.swift +// UtiliKit iOS +// +// Created by Nathan Chiu on 5/19/22. +// Copyright © 2022 Bottle Rocket Studios. All rights reserved. +// + +import XCTest +@testable import UtiliKit + +class CGSizeTests: XCTestCase { + + func test_one_correctSize() { + XCTAssertEqual(CGSize.one.width, 1) + XCTAssertEqual(CGSize.one.height, 1) + } +} diff --git a/Tests/ComparableTests.swift b/Tests/ComparableTests.swift new file mode 100644 index 0000000..8e8dcb7 --- /dev/null +++ b/Tests/ComparableTests.swift @@ -0,0 +1,59 @@ +// +// ComparableTests.swift +// UtiliKit iOS Tests +// +// Created by Nathan Chiu on 5/19/22. +// Copyright © 2022 Bottle Rocket Studios. All rights reserved. +// + +import XCTest +@testable import UtiliKit + +class ComparableTests: XCTestCase { + + let minValue = 0 + let maxValue = 10 + let middleValue = 5 + let lessThanValue = -5 + let greaterThanValue = 15 + + func test_clamping_lessThanMin() { + XCTAssertEqual(lessThanValue.clamped(min: minValue, max: maxValue), minValue) + } + + func test_clamping_equalToMin() { + XCTAssertEqual(minValue.clamped(min: minValue, max: maxValue), minValue) + } + + func test_clamping_inMinMax() { + XCTAssertEqual(middleValue.clamped(min: minValue, max: maxValue), middleValue) + } + + func test_clamping_equalToMax() { + XCTAssertEqual(maxValue.clamped(min: minValue, max: maxValue), maxValue) + } + + func test_clamping_greaterThanMax() { + XCTAssertEqual(greaterThanValue.clamped(min: minValue, max: maxValue), maxValue) + } + + func test_clamping_lessThanMinRange() { + XCTAssertEqual(lessThanValue.clamped(in: minValue...maxValue), minValue) + } + + func test_clamping_equalToMinRange() { + XCTAssertEqual(minValue.clamped(in: minValue...maxValue), minValue) + } + + func test_clamping_inRange() { + XCTAssertEqual(middleValue.clamped(in: minValue...maxValue), middleValue) + } + + func test_clamping_equalToMaxRange() { + XCTAssertEqual(maxValue.clamped(in: minValue...maxValue), maxValue) + } + + func test_clamping_greaterThanMaxRange() { + XCTAssertEqual(greaterThanValue.clamped(in: minValue...maxValue), maxValue) + } +} diff --git a/UtiliKit.xcodeproj/project.pbxproj b/UtiliKit.xcodeproj/project.pbxproj index bffc70b..1052d3b 100644 --- a/UtiliKit.xcodeproj/project.pbxproj +++ b/UtiliKit.xcodeproj/project.pbxproj @@ -53,6 +53,11 @@ 0EC7F0A9279231D500F712F3 /* UtiliKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC7F031279231A300F712F3 /* UtiliKitTests.swift */; }; 0EC7F0AA279231D500F712F3 /* ObfuscationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC7F034279231A300F712F3 /* ObfuscationTests.swift */; }; 0EC7F0ED27923DD400F712F3 /* SnapshotTesting.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EC7F0EC27923DD400F712F3 /* SnapshotTesting.xcframework */; }; + 5919D94228354BFC00E1626E /* UIScrollView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5919D94128354BFC00E1626E /* UIScrollView+Extensions.swift */; }; + 5919D94428354EE600E1626E /* CGSize+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5919D94328354EE600E1626E /* CGSize+Extensions.swift */; }; + 5919D94628369E1400E1626E /* Comparable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5919D94528369E1300E1626E /* Comparable+Extensions.swift */; }; + 5919D94928369F3C00E1626E /* CGSizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5919D94728369EA400E1626E /* CGSizeTests.swift */; }; + 5919D94B28369F6D00E1626E /* ComparableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5919D94A28369F6D00E1626E /* ComparableTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -122,6 +127,11 @@ 0EC7F0A3279231C600F712F3 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = SOURCE_ROOT; }; 0EC7F0A4279231C600F712F3 /* Dangerfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dangerfile.swift; sourceTree = SOURCE_ROOT; }; 0EC7F0EC27923DD400F712F3 /* SnapshotTesting.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SnapshotTesting.xcframework; path = Carthage/Build/SnapshotTesting.xcframework; sourceTree = ""; }; + 5919D94128354BFC00E1626E /* UIScrollView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+Extensions.swift"; sourceTree = ""; }; + 5919D94328354EE600E1626E /* CGSize+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGSize+Extensions.swift"; sourceTree = ""; }; + 5919D94528369E1300E1626E /* Comparable+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Comparable+Extensions.swift"; sourceTree = ""; }; + 5919D94728369EA400E1626E /* CGSizeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSizeTests.swift; sourceTree = ""; }; + 5919D94A28369F6D00E1626E /* ComparableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComparableTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -199,9 +209,12 @@ 0EC7F006279231A300F712F3 /* General */ = { isa = PBXGroup; children = ( - 0EC7F007279231A300F712F3 /* UIView+Extensions.swift */, - 0EC7F008279231A300F712F3 /* FileManager+Extensions.swift */, + 5919D94328354EE600E1626E /* CGSize+Extensions.swift */, 0E666BDD27A074E7001082D0 /* CLLocationCoordinate2D+Extensions.swift */, + 5919D94528369E1300E1626E /* Comparable+Extensions.swift */, + 0EC7F008279231A300F712F3 /* FileManager+Extensions.swift */, + 5919D94128354BFC00E1626E /* UIScrollView+Extensions.swift */, + 0EC7F007279231A300F712F3 /* UIView+Extensions.swift */, 0EC7F009279231A300F712F3 /* URL+Extensions.swift */, ); path = General; @@ -322,6 +335,8 @@ 0EC7F02E279231A300F712F3 /* Tests */ = { isa = PBXGroup; children = ( + 5919D94728369EA400E1626E /* CGSizeTests.swift */, + 5919D94A28369F6D00E1626E /* ComparableTests.swift */, 0EC7F02F279231A300F712F3 /* ScrollingPageControlTests.swift */, 0EC7F030279231A300F712F3 /* DateTests.swift */, 0EC7F031279231A300F712F3 /* UtiliKitTests.swift */, @@ -475,10 +490,12 @@ 0EC7F060279231A400F712F3 /* ScrollingPageControl.swift in Sources */, 0EC7F06A279231A400F712F3 /* VersionConfig.swift in Sources */, 0EC7F068279231A400F712F3 /* ContainerViewController.swift in Sources */, + 5919D94228354BFC00E1626E /* UIScrollView+Extensions.swift in Sources */, 0EC7F066279231A400F712F3 /* DefaultContainerTransitionAnimator.swift in Sources */, 0E666BDE27A074E7001082D0 /* CLLocationCoordinate2D+Extensions.swift in Sources */, 0EC7F05F279231A400F712F3 /* URL+Extensions.swift in Sources */, 0EC7F05B279231A400F712F3 /* UICollectionView+Extensions.swift in Sources */, + 5919D94628369E1400E1626E /* Comparable+Extensions.swift in Sources */, 0EC7F05C279231A400F712F3 /* NibLoadable.swift in Sources */, 0EC7F057279231A400F712F3 /* Configurable.swift in Sources */, 0EC7F06F279231A400F712F3 /* Date+Extensions.swift in Sources */, @@ -496,6 +513,7 @@ 0EC7F061279231A400F712F3 /* ContainerViewController+ManagedChildren.swift in Sources */, 0EC7F056279231A400F712F3 /* StoryboardIdentifiable.swift in Sources */, 0EC7F055279231A400F712F3 /* ReuseIdentifiable.swift in Sources */, + 5919D94428354EE600E1626E /* CGSize+Extensions.swift in Sources */, 0EC7F06D279231A400F712F3 /* ObfuscatedKey.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -505,10 +523,12 @@ buildActionMask = 2147483647; files = ( 0EC7F0A5279231D500F712F3 /* ScrollingPageControlTests.swift in Sources */, + 5919D94B28369F6D00E1626E /* ComparableTests.swift in Sources */, 0EC7F0A8279231D500F712F3 /* ContainerTests.swift in Sources */, 0EC7F0A9279231D500F712F3 /* UtiliKitTests.swift in Sources */, 0EC7F0AA279231D500F712F3 /* ObfuscationTests.swift in Sources */, 0EC7F0A7279231D500F712F3 /* ActiveLabelTests.swift in Sources */, + 5919D94928369F3C00E1626E /* CGSizeTests.swift in Sources */, 0EC7F0A6279231D500F712F3 /* DateTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0;