From d3870a3523988e1aad932a317ecb19ae2bc379f4 Mon Sep 17 00:00:00 2001 From: Nathan Chiu Date: Wed, 18 May 2022 11:03:11 -0500 Subject: [PATCH 1/4] Adding UIScrollView extensions --- Sources/General/CGSize+Extensions.swift | 17 +++++ Sources/General/UIScrollView+Extensions.swift | 67 +++++++++++++++++++ UtiliKit.xcodeproj/project.pbxproj | 12 +++- 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 Sources/General/CGSize+Extensions.swift create mode 100644 Sources/General/UIScrollView+Extensions.swift 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/UIScrollView+Extensions.swift b/Sources/General/UIScrollView+Extensions.swift new file mode 100644 index 0000000..3214924 --- /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: min(max(horizontalPercentage, 0), 1), vertical: min(max(verticalPercentage, 0), 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/UtiliKit.xcodeproj/project.pbxproj b/UtiliKit.xcodeproj/project.pbxproj index bffc70b..b906751 100644 --- a/UtiliKit.xcodeproj/project.pbxproj +++ b/UtiliKit.xcodeproj/project.pbxproj @@ -53,6 +53,8 @@ 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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -122,6 +124,8 @@ 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -199,9 +203,11 @@ 0EC7F006279231A300F712F3 /* General */ = { isa = PBXGroup; children = ( - 0EC7F007279231A300F712F3 /* UIView+Extensions.swift */, - 0EC7F008279231A300F712F3 /* FileManager+Extensions.swift */, + 5919D94328354EE600E1626E /* CGSize+Extensions.swift */, 0E666BDD27A074E7001082D0 /* CLLocationCoordinate2D+Extensions.swift */, + 0EC7F008279231A300F712F3 /* FileManager+Extensions.swift */, + 5919D94128354BFC00E1626E /* UIScrollView+Extensions.swift */, + 0EC7F007279231A300F712F3 /* UIView+Extensions.swift */, 0EC7F009279231A300F712F3 /* URL+Extensions.swift */, ); path = General; @@ -475,6 +481,7 @@ 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 */, @@ -496,6 +503,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; From 50412dcf031ed1cbc2ffc8e0160db8ea717f617a Mon Sep 17 00:00:00 2001 From: Nathan Chiu Date: Thu, 19 May 2022 11:00:42 -0500 Subject: [PATCH 2/4] Adding clamping and unit tests --- Sources/General/Comparable+Extensions.swift | 28 ++++++++++ Sources/General/UIScrollView+Extensions.swift | 2 +- Tests/CGSizeTests.swift | 18 +++++++ Tests/ComparableTests.swift | 53 +++++++++++++++++++ UtiliKit.xcodeproj/project.pbxproj | 12 +++++ 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 Sources/General/Comparable+Extensions.swift create mode 100644 Tests/CGSizeTests.swift create mode 100644 Tests/ComparableTests.swift diff --git a/Sources/General/Comparable+Extensions.swift b/Sources/General/Comparable+Extensions.swift new file mode 100644 index 0000000..aa550a8 --- /dev/null +++ b/Sources/General/Comparable+Extensions.swift @@ -0,0 +1,28 @@ +// +// 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) + } +} diff --git a/Sources/General/UIScrollView+Extensions.swift b/Sources/General/UIScrollView+Extensions.swift index 3214924..d734dd8 100644 --- a/Sources/General/UIScrollView+Extensions.swift +++ b/Sources/General/UIScrollView+Extensions.swift @@ -37,7 +37,7 @@ extension UIScrollView { } let horizontalPercentage = insetAdjustedContentOffset.x / totalScrollableWidth - return (horizontal: min(max(horizontalPercentage, 0), 1), vertical: min(max(verticalPercentage, 0), 1)) + 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` 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..bff3d52 --- /dev/null +++ b/Tests/ComparableTests.swift @@ -0,0 +1,53 @@ +// +// 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 { + + func test_clamping_lessThanMin() { + XCTAssertEqual(-5.clamped(min: 0, max: 10), 0) + } + + func test_clamping_equalToMin() { + XCTAssertEqual(0.clamped(min: 0, max: 10), 0) + } + + func test_clamping_inMinMax() { + XCTAssertEqual(5.clamped(min: 0, max: 10), 5) + } + + func test_clamping_equalToMax() { + XCTAssertEqual(10.clamped(min: 0, max: 10), 10) + } + + func test_clamping_greaterThanMax() { + XCTAssertEqual(15.clamped(min: 0, max: 10), 10) + } + + func test_clamping_lessThanMinRange() { + XCTAssertEqual(-5.clamped(in: 0...10), 0) + } + + func test_clamping_equalToMinRange() { + XCTAssertEqual(0.clamped(in: 0...10), 0) + } + + func test_clamping_inRange() { + XCTAssertEqual(5.clamped(in: 0...10), 5) + } + + func test_clamping_equalToMaxRange() { + XCTAssertEqual(10.clamped(in: 0...10), 10) + } + + func test_clamping_greaterThanMaxRange() { + XCTAssertEqual(15.clamped(in: 0...10), 10) + } +} diff --git a/UtiliKit.xcodeproj/project.pbxproj b/UtiliKit.xcodeproj/project.pbxproj index b906751..1052d3b 100644 --- a/UtiliKit.xcodeproj/project.pbxproj +++ b/UtiliKit.xcodeproj/project.pbxproj @@ -55,6 +55,9 @@ 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 */ @@ -126,6 +129,9 @@ 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 */ @@ -205,6 +211,7 @@ children = ( 5919D94328354EE600E1626E /* CGSize+Extensions.swift */, 0E666BDD27A074E7001082D0 /* CLLocationCoordinate2D+Extensions.swift */, + 5919D94528369E1300E1626E /* Comparable+Extensions.swift */, 0EC7F008279231A300F712F3 /* FileManager+Extensions.swift */, 5919D94128354BFC00E1626E /* UIScrollView+Extensions.swift */, 0EC7F007279231A300F712F3 /* UIView+Extensions.swift */, @@ -328,6 +335,8 @@ 0EC7F02E279231A300F712F3 /* Tests */ = { isa = PBXGroup; children = ( + 5919D94728369EA400E1626E /* CGSizeTests.swift */, + 5919D94A28369F6D00E1626E /* ComparableTests.swift */, 0EC7F02F279231A300F712F3 /* ScrollingPageControlTests.swift */, 0EC7F030279231A300F712F3 /* DateTests.swift */, 0EC7F031279231A300F712F3 /* UtiliKitTests.swift */, @@ -486,6 +495,7 @@ 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 */, @@ -513,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; From e0c51a8ea666151f9a092286bad9de97f3c82a51 Mon Sep 17 00:00:00 2001 From: Nathan Chiu Date: Thu, 19 May 2022 11:11:13 -0500 Subject: [PATCH 3/4] Fixing unit tests --- Sources/General/Comparable+Extensions.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/General/Comparable+Extensions.swift b/Sources/General/Comparable+Extensions.swift index aa550a8..a954186 100644 --- a/Sources/General/Comparable+Extensions.swift +++ b/Sources/General/Comparable+Extensions.swift @@ -25,4 +25,11 @@ extension Comparable { 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) + } } From 1b29659d317d7528311568f2b645182008a82982 Mon Sep 17 00:00:00 2001 From: Nathan Chiu Date: Thu, 19 May 2022 11:21:59 -0500 Subject: [PATCH 4/4] Code cleanup --- Tests/ComparableTests.swift | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/Tests/ComparableTests.swift b/Tests/ComparableTests.swift index bff3d52..8e8dcb7 100644 --- a/Tests/ComparableTests.swift +++ b/Tests/ComparableTests.swift @@ -11,43 +11,49 @@ import XCTest class ComparableTests: XCTestCase { + let minValue = 0 + let maxValue = 10 + let middleValue = 5 + let lessThanValue = -5 + let greaterThanValue = 15 + func test_clamping_lessThanMin() { - XCTAssertEqual(-5.clamped(min: 0, max: 10), 0) + XCTAssertEqual(lessThanValue.clamped(min: minValue, max: maxValue), minValue) } func test_clamping_equalToMin() { - XCTAssertEqual(0.clamped(min: 0, max: 10), 0) + XCTAssertEqual(minValue.clamped(min: minValue, max: maxValue), minValue) } func test_clamping_inMinMax() { - XCTAssertEqual(5.clamped(min: 0, max: 10), 5) + XCTAssertEqual(middleValue.clamped(min: minValue, max: maxValue), middleValue) } func test_clamping_equalToMax() { - XCTAssertEqual(10.clamped(min: 0, max: 10), 10) + XCTAssertEqual(maxValue.clamped(min: minValue, max: maxValue), maxValue) } func test_clamping_greaterThanMax() { - XCTAssertEqual(15.clamped(min: 0, max: 10), 10) + XCTAssertEqual(greaterThanValue.clamped(min: minValue, max: maxValue), maxValue) } func test_clamping_lessThanMinRange() { - XCTAssertEqual(-5.clamped(in: 0...10), 0) + XCTAssertEqual(lessThanValue.clamped(in: minValue...maxValue), minValue) } func test_clamping_equalToMinRange() { - XCTAssertEqual(0.clamped(in: 0...10), 0) + XCTAssertEqual(minValue.clamped(in: minValue...maxValue), minValue) } func test_clamping_inRange() { - XCTAssertEqual(5.clamped(in: 0...10), 5) + XCTAssertEqual(middleValue.clamped(in: minValue...maxValue), middleValue) } func test_clamping_equalToMaxRange() { - XCTAssertEqual(10.clamped(in: 0...10), 10) + XCTAssertEqual(maxValue.clamped(in: minValue...maxValue), maxValue) } func test_clamping_greaterThanMaxRange() { - XCTAssertEqual(15.clamped(in: 0...10), 10) + XCTAssertEqual(greaterThanValue.clamped(in: minValue...maxValue), maxValue) } }