diff --git a/Form.xcodeproj/project.pbxproj b/Form.xcodeproj/project.pbxproj index b3dd962..883b24d 100644 --- a/Form.xcodeproj/project.pbxproj +++ b/Form.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 5B4ABD3A2257365C0073FACE /* TableKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4ABD392257365C0073FACE /* TableKitTests.swift */; }; 721954D821A44E450090F9E3 /* MinimumSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 721954D721A44E450090F9E3 /* MinimumSize.swift */; }; 722EB65023266A99003AA360 /* CollectionKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722EB64F23266A99003AA360 /* CollectionKitTests.swift */; }; + 722EB63723242D37003AA360 /* ScrollViewVerticalContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722EB63623242D37003AA360 /* ScrollViewVerticalContextTests.swift */; }; 724EC30D2241513D001F3E11 /* UILabel+StylingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 724EC30C2241513D001F3E11 /* UILabel+StylingTests.swift */; }; 7270AFB0201FAB7C004DAAA3 /* ViewLayoutArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7270AFAF201FAB7C004DAAA3 /* ViewLayoutArea.swift */; }; 72C5ED6F226F432600E32125 /* UIScrollView+PinningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72C5ED6E226F432600E32125 /* UIScrollView+PinningTests.swift */; }; @@ -136,6 +137,7 @@ 5B4ABD392257365C0073FACE /* TableKitTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableKitTests.swift; sourceTree = ""; }; 721954D721A44E450090F9E3 /* MinimumSize.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MinimumSize.swift; path = Form/MinimumSize.swift; sourceTree = ""; }; 722EB64F23266A99003AA360 /* CollectionKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionKitTests.swift; sourceTree = ""; }; + 722EB63623242D37003AA360 /* ScrollViewVerticalContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewVerticalContextTests.swift; sourceTree = ""; }; 724EC30C2241513D001F3E11 /* UILabel+StylingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+StylingTests.swift"; sourceTree = ""; }; 7270AFAF201FAB7C004DAAA3 /* ViewLayoutArea.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ViewLayoutArea.swift; path = Form/ViewLayoutArea.swift; sourceTree = ""; }; 72C5ED6E226F432600E32125 /* UIScrollView+PinningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+PinningTests.swift"; sourceTree = ""; }; @@ -277,6 +279,7 @@ F6B81B9320CA906000B6AC39 /* NumberEditorTests.swift */, 724EC30C2241513D001F3E11 /* UILabel+StylingTests.swift */, 72C5ED6E226F432600E32125 /* UIScrollView+PinningTests.swift */, + 722EB63623242D37003AA360 /* ScrollViewVerticalContextTests.swift */, F62E45B81CABFB5300C6867E /* Info.plist */, ); path = FormTests; @@ -578,6 +581,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 722EB63723242D37003AA360 /* ScrollViewVerticalContextTests.swift in Sources */, CFD2FFE3221323BF002D4D36 /* TextStyleTests.swift in Sources */, F604260A20B6A47E00BC4CAB /* ParentChildRelationalTests.swift in Sources */, F6B81B9420CA906000B6AC39 /* NumberEditorTests.swift in Sources */, diff --git a/Form/UIScrollView+Keyboard.swift b/Form/UIScrollView+Keyboard.swift index 6a7cb0f..825db0f 100644 --- a/Form/UIScrollView+Keyboard.swift +++ b/Form/UIScrollView+Keyboard.swift @@ -58,19 +58,19 @@ public extension UIScrollView { let bag = DisposeBag() var lastResponder = firstResponder - adjustContentOffset(adjustInsets) + self.adjustContentOffsetToRevealFirstResponder(adjustInsets) bag += keyboardSignal(priority: .contentOffset).onValue { event -> Void in lastResponder = self.firstResponder guard case let .willShow(_, animation) = event else { return } - animation.animate { self.adjustContentOffset(adjustInsets) } + animation.animate { self.adjustContentOffsetToRevealFirstResponder(adjustInsets) } } bag += NotificationCenter.default.signal(forName: UITextField.textDidBeginEditingNotification).onValue { _ in DispatchQueue.main.async { // Make sure to run after onKeyboardEvent above. defer { lastResponder = self.firstResponder } guard self.firstResponder != lastResponder else { return } - UIView.animate(withDuration: 0.3) { self.adjustContentOffset(adjustInsets) } + UIView.animate(withDuration: 0.3) { self.adjustContentOffsetToRevealFirstResponder(adjustInsets) } } } @@ -150,33 +150,58 @@ private extension UIScrollView { } } - func adjustContentOffset(_ adjustInsets: (UIView) -> UIEdgeInsets) { - guard let firstResponder = firstResponder as? UIView else { return } + func adjustContentOffsetToRevealFirstResponder(_ adjustInsets: (_ firstResponder: UIView) -> UIEdgeInsets) { + if let targetVisibleRect = self.targetVisibleRectToRevealFirstResponder(adjustInsets) { + self.scrollRectToVisible(targetVisibleRect, animated: false) + } + } + + func targetVisibleRectToRevealFirstResponder(_ adjustInsets: (_ firstResponder: UIView) -> UIEdgeInsets) -> CGRect? { + guard let frameToFocus = self.firstResponderAdjustedFrame(adjustInsets: adjustInsets) else { return nil } + + let verticalFocusPosition = ScrollViewVerticalContext( + visibleRectHeight: self.bounds.height, + visibleRectOffsetY: self.contentOffset.y, + visibleRectInsetTop: self.contentInset.top, + visibleRectInsetBottom: self.contentInset.bottom + ).targetFocusPosition(for: frameToFocus) + + return verticalFocusPosition.flatMap { CGRect(x: 0, y: $0, width: 1, height: 1) } + } - let viewRect = frame.inset(by: contentInset) - let firstBounds = firstResponder.bounds.inset(by: adjustInsets(firstResponder)) - let firstFrame = convert(firstBounds, from: firstResponder) + func firstResponderAdjustedFrame(adjustInsets: (_ firstResponder: UIView) -> UIEdgeInsets) -> CGRect? { + guard let firstResponder = firstResponder as? UIView else { return nil } + let insetAdjustment = adjustInsets(firstResponder) + let frameToFocus = firstResponder.frame.inset(by: insetAdjustment) + return self.convert(frameToFocus, from: firstResponder) + } +} - var portRect = viewRect - portRect.origin.y += contentOffset.y +// Internal helper to move scroll view vertical position calculations outside of the view so that we can test them +struct ScrollViewVerticalContext { + let visibleRectHeight: CGFloat + let visibleRectOffsetY: CGFloat + let visibleRectInsetTop: CGFloat + let visibleRectInsetBottom: CGFloat - // Don't let horizontal values affect contains below. - portRect.origin.x -= 1000 - portRect.size.width += 2000 + // Calculates the vertical position of the area that needs to become visible for the given frame to be focused. + // It calculates the smallest movement needed based on the relative position on the `frameToFocus`. + // Nil if the frame is already focused. + func targetFocusPosition(for frameToFocus: CGRect) -> CGFloat? { + let visibleRectMinY = visibleRectOffsetY - visibleRectInsetTop + let visibleRectMaxY = visibleRectMinY + visibleRectHeight - visibleRectInsetBottom - guard !portRect.contains(firstFrame) else { return } + let isBelowTop = frameToFocus.minY >= visibleRectMinY + let isAboveBottom = frameToFocus.maxY <= visibleRectMaxY + let isFirstResponderVisible = isBelowTop && isAboveBottom - var offset = contentOffset - let bottom = firstFrame.maxY - viewRect.size.height - contentInset.top - let marginY = layoutMargins.top + contentInset.top + guard !isFirstResponderVisible else { return nil } - if bottom > -max(marginY, firstFrame.height) { - offset.y = bottom + if !isBelowTop { + return max(0, frameToFocus.minY) } else { - offset.y = -marginY + return max(0, frameToFocus.maxY) } - - setContentOffset(offset, animated: false) } } diff --git a/FormTests/ScrollViewVerticalContextTests.swift b/FormTests/ScrollViewVerticalContextTests.swift new file mode 100644 index 0000000..c464426 --- /dev/null +++ b/FormTests/ScrollViewVerticalContextTests.swift @@ -0,0 +1,67 @@ +// +// UIScrollView+KeyboardTests.swift +// FormTests +// +// Created by Nataliya Patsovska on 2019-09-07. +// Copyright © 2019 iZettle. All rights reserved. +// + +import XCTest +@testable import Form +import Flow + +class ScrollViewVerticalContextTests: XCTestCase { + func testFocusPosition_noOffsetContext() { + let verticalContext = ScrollViewVerticalContext( + visibleRectHeight: 100, + visibleRectOffsetY: 0, + visibleRectInsetTop: 0, + visibleRectInsetBottom: 0 + ) + + // whitin the visible area + XCTAssertNil(verticalContext.targetFocusPosition(for: CGRect(x: 0, y: 0, width: 100, height: 50))) + XCTAssertNil(verticalContext.targetFocusPosition(for: CGRect(x: 0, y: 50, width: 100, height: 50))) + + // below the visible area + XCTAssertEqual(verticalContext.targetFocusPosition(for: CGRect(x: 0, y: 51, width: 100, height: 50)), 101) + } + + func testFocusPosition_yOffsetContext() { + let verticalContext = ScrollViewVerticalContext( + visibleRectHeight: 100, + visibleRectOffsetY: 50, + visibleRectInsetTop: 0, + visibleRectInsetBottom: 0 + ) + + // whitin the visible area + XCTAssertNil(verticalContext.targetFocusPosition(for: CGRect(x: 0, y: 50, width: 100, height: 50))) + XCTAssertNil(verticalContext.targetFocusPosition(for: CGRect(x: 0, y: 100, width: 100, height: 50))) + + // above the visible area + XCTAssertEqual(verticalContext.targetFocusPosition(for: CGRect(x: 0, y: 49, width: 100, height: 50)), 49) + + // below the visible area + XCTAssertEqual(verticalContext.targetFocusPosition(for: CGRect(x: 0, y: 101, width: 100, height: 50)), 151) + } + + func testFocusPosition_insettedContext() { + let verticalContext = ScrollViewVerticalContext( + visibleRectHeight: 100, + visibleRectOffsetY: 50, + visibleRectInsetTop: -10, + visibleRectInsetBottom: 20 + ) + + // whitin the visible area + XCTAssertNil(verticalContext.targetFocusPosition(for: CGRect(x: 0, y: 60, width: 100, height: 50))) + XCTAssertNil(verticalContext.targetFocusPosition(for: CGRect(x: 0, y: 90, width: 100, height: 50))) + + // above the visible area + XCTAssertEqual(verticalContext.targetFocusPosition(for: CGRect(x: 0, y: 59, width: 100, height: 50)), 59) + + // below the visible area + XCTAssertEqual(verticalContext.targetFocusPosition(for: CGRect(x: 0, y: 91, width: 100, height: 50)), 141) + } +}