Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: keyboard offset on iOS #695

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
efd6980
feat: add basic fs structure for managing keyboard offset (just save …
kirillzyusko Nov 2, 2024
81d9846
feat: more changes to actually move progress further
kirillzyusko Nov 3, 2024
ff7506d
fix: lint, remove iav
kirillzyusko Nov 4, 2024
c215689
fix: iOS 16 incorrect height in onEnd event
kirillzyusko Nov 4, 2024
b90aaa9
chore: update TODO
kirillzyusko Nov 7, 2024
56116e4
fix: grow of input breaks `onInteractive` handler
kirillzyusko Nov 8, 2024
32d8e4e
docs: document new API
kirillzyusko Nov 16, 2024
82b0501
fix: objc lint
kirillzyusko Nov 16, 2024
7556b7b
fix: lint ts
kirillzyusko Nov 16, 2024
0b59cde
fix: platform inconsistency, fabric Android build
kirillzyusko Nov 16, 2024
ef6777a
fix: lint ts
kirillzyusko Nov 16, 2024
bd1ff10
fix: fabric build
kirillzyusko Nov 16, 2024
8cf282f
fix: suppress detekt violation
kirillzyusko Nov 16, 2024
3e0876f
fix: conflicts after rebasing to main
kirillzyusko Nov 16, 2024
d701ee3
fix: iOS unit tests
kirillzyusko Nov 16, 2024
4f5d77b
fix: mock `nativeID` to pass iOS unit tests
kirillzyusko Nov 16, 2024
3846dc8
fix: swiftformat
kirillzyusko Nov 18, 2024
1d87523
refactor: create separate class to simplify KeyboardMovementObserver …
kirillzyusko Nov 24, 2024
9c6dbd6
refactor: keep progress
kirillzyusko Nov 24, 2024
10c933a
chore: update list of bugs
kirillzyusko Nov 24, 2024
6ba2ee0
fix: no random onStart/onEnd events during interactive keyboard dismi…
kirillzyusko Nov 26, 2024
9552ce0
feat: try to de-attach input accessory view only when keyboard got cl…
kirillzyusko Nov 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions TODO
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
- (x) close after interactive gesture - sudden jump of 75 pixels -> fixed by detaching iav during `onInteractive`
- (x) keyboard hidden -> remove inputAccessoryView - done in resignFirstResponder
- (x) iOS 16 - after attaching fake iav we dispatch onEnd with height - 248 (298 - 50) -> ignore events after attaching iav, because in keyboardDidAppear `position` can be a random value (291 or 341)
- (x) text input grow -> can not make interactive gesture (Optional(424.6666666666667) 424.66666666666674) - maybe because of frequent conversions to CGFloat? <- fixed via rounding
- 1 show after interactive - keyboard height is 386
- 2 show after interactive - iav is not attached again
- (x) 3 two events get dispatched when we remove iav in onInteractive - (dispatch `shouldIgnoreKeyboardEvents` in .hide (KEA)? - doesn't work because swizzle calls .hide when keyboard appear)
- (x) 4 two events get dispatched after 2nd onInteractive (fix with pan gesture responder - when it's active ignore plain keyboard events)

:: (x) call actual code for hide only once
::: (x) dispatch ignore event when we actually hide iav (will fix 3)
::: (add `lastOffset`) - will fix 1 <- last time when I added it was resetting somehow, need to figure out where and why exactly
::: add code in `keyboardDidAppear` in KAE to increase/attach iav again (will fix 2)
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,12 @@ class KeyboardGestureAreaViewManager(
) {
manager.setScrollKeyboardOffScreenWhenVisible(view as KeyboardGestureAreaReactViewGroup, value)
}

@ReactProp(name = "textInputNativeID")
override fun setTextInputNativeID(
view: ReactViewGroup,
value: String?,
) {
// no-op
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,13 @@ class KeyboardGestureAreaViewManager(
) {
manager.setScrollKeyboardOffScreenWhenVisible(view, value)
}

@Suppress("detekt:UnusedParameter")
@ReactProp(name = "textInputNativeID")
fun setTextInputNativeID(
view: KeyboardGestureAreaReactViewGroup,
value: String,
) {
// no-op
}
}
23 changes: 13 additions & 10 deletions docs/docs/api/keyboard-gesture-area.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,6 @@ keywords:
]
---

<!-- prettier-ignore-start -->
<!-- we explicitly specify title and h1 because we add badge to h1 and we don't want this element to go to table of contents -->
<!-- markdownlint-disable-next-line MD025 -->
# KeyboardGestureArea <div className="label android"></div>
<!-- prettier-ignore-end -->

`KeyboardGestureArea` allows you to define a region on the screen, where gestures will control the keyboard position.

:::info Platform availability
Expand All @@ -28,27 +22,36 @@ This component is available only for Android >= 11. For iOS and Android < 11 it

Extra distance to the keyboard. Default is `0`.

### `interpolator`
### `interpolator` <div className="label android"></div>

String with possible values `linear` and `ios`:

- **ios** - interactive keyboard dismissing will work as in iOS: swipes in non-keyboard area will not affect keyboard positioning, but if your swipe touches keyboard - keyboard will follow finger position.
- **linear** - gestures inside the component will linearly affect the position of the keyboard, i.e. if the user swipes down by 20 pixels, then the keyboard will also be moved down by 20 pixels, even if the gesture was not made over the keyboard area.

### `showOnSwipeUp`
### `showOnSwipeUp` <div className="label android"></div>

A boolean prop which allows to customize interactive keyboard behavior. If set to `true` then it allows to show keyboard (if it's already closed) by swipe up gesture. `false` by default.

### `enableSwipeToDismiss`
### `enableSwipeToDismiss` <div className="label android"></div>

A boolean prop which allows to customize interactive keyboard behavior. If set to `false`, then any gesture will not affect keyboard position if the keyboard is shown. `true` by default.

### `textInputNativeID` <div className="label ios"></div>

A corresponding `nativeID` value from the corresponding `TextInput`.

## Example

```tsx
<KeyboardGestureArea interpolator="ios" offset={50}>
<KeyboardGestureArea
interpolator="ios"
offset={50}
textInputNativeID="composer"
>
<ScrollView>
{/* The other UI components of application in your tree */}
</ScrollView>
<TextInput nativeID="composer" />
</KeyboardGestureArea>
```
46 changes: 33 additions & 13 deletions example/src/screens/Examples/InteractiveKeyboardIOS/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import React, { useCallback, useRef } from "react";
import { TextInput, View } from "react-native";
import { useKeyboardHandler } from "react-native-keyboard-controller";
import React, { useCallback, useRef, useState } from "react";
import { TextInput } from "react-native";
import {
KeyboardGestureArea,
useKeyboardHandler,
} from "react-native-keyboard-controller";
import Reanimated, {
useAnimatedProps,
useAnimatedScrollHandler,
Expand All @@ -13,6 +16,8 @@ import { history } from "../../../components/Message/data";

import styles from "./styles";

import type { LayoutChangeEvent } from "react-native";

const AnimatedTextInput = Reanimated.createAnimatedComponent(TextInput);

const useKeyboardAnimation = () => {
Expand All @@ -38,8 +43,8 @@ const useKeyboardAnimation = () => {
return;
}

progress.value = e.progress;
height.value = e.height;
// progress.value = e.progress;
// height.value = e.height;

inset.value = e.height;
// Math.max is needed to prevent overscroll when keyboard hides (and user scrolled to the top, for example)
Expand All @@ -54,10 +59,10 @@ const useKeyboardAnimation = () => {
onMove: (e) => {
"worklet";

if (shouldUseOnMoveHandler.value) {
progress.value = e.progress;
height.value = e.height;
}
// if (shouldUseOnMoveHandler.value) {
progress.value = e.progress;
height.value = e.height;
// }
},
onEnd: (e) => {
"worklet";
Expand Down Expand Up @@ -86,6 +91,11 @@ const contentContainerStyle = {
function InteractiveKeyboard() {
const ref = useRef<Reanimated.ScrollView>(null);
const { height, onScroll, inset, offset } = useKeyboardAnimation();
const [inputHeight, setInputHeight] = useState(TEXT_INPUT_HEIGHT);

const onInputLayoutChanged = useCallback((e: LayoutChangeEvent) => {
setInputHeight(e.nativeEvent.layout.height);
}, []);

const scrollToBottom = useCallback(() => {
ref.current?.scrollToEnd({ animated: false });
Expand All @@ -94,7 +104,7 @@ function InteractiveKeyboard() {
const textInputStyle = useAnimatedStyle(
() => ({
position: "absolute",
height: TEXT_INPUT_HEIGHT,
minHeight: TEXT_INPUT_HEIGHT,
width: "100%",
backgroundColor: "#BCBCBC",
transform: [{ translateY: -height.value }],
Expand All @@ -113,7 +123,11 @@ function InteractiveKeyboard() {
}));

return (
<View style={styles.container}>
<KeyboardGestureArea
offset={inputHeight}
style={styles.container}
textInputNativeID="chat-input"
>
<Reanimated.ScrollView
ref={ref}
// simulation of `automaticallyAdjustKeyboardInsets` behavior on RN < 0.73
Expand All @@ -130,8 +144,14 @@ function InteractiveKeyboard() {
<Message key={index} {...message} />
))}
</Reanimated.ScrollView>
<AnimatedTextInput style={textInputStyle} testID="chat.input" />
</View>
<AnimatedTextInput
multiline
nativeID="chat-input"
style={textInputStyle}
testID="chat.input"
onLayout={onInputLayoutChanged}
/>
</KeyboardGestureArea>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ public extension UIView {
var reactTag: NSNumber {
return tag as NSNumber
}

var nativeID: String {
return accessibilityIdentifier ?? ""
}
}
4 changes: 4 additions & 0 deletions ios/extensions/Notification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ extension Notification {
return (duration, keyboardFrame)
}
}

extension Notification.Name {
static let shouldIgnoreKeyboardEvents = Notification.Name("shouldIgnoreKeyboardEvents")
}
7 changes: 7 additions & 0 deletions ios/extensions/UIResponder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ public extension Optional where Wrapped == UIResponder {
return (self as? UIView)?.superview?.reactTag ?? -1
#endif
}

var nativeID: String? {
if let superview = (self as? UIView)?.superview as? UIView {
return superview.nativeID as String?
}
return nil
}
}

public extension Optional where Wrapped: UIResponder {
Expand Down
60 changes: 60 additions & 0 deletions ios/interactive/InvisibleInputAccessoryView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//
// InvisibleInputAccessoryView.swift
// Pods
//
// Created by Kiryl Ziusko on 01/11/2024.
//

import Foundation
import UIKit

public class InvisibleInputAccessoryView: UIView {
var isShown = true

override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}

public convenience init(height: CGFloat) {
self.init(frame: CGRect(x: 0, y: 0, width: 0, height: height))
}

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupView()
}

override public func point(inside _: CGPoint, with _: UIEvent?) -> Bool {
// Return false to allow touch events to pass through
return false
}

public func updateHeight(to newHeight: CGFloat) {
frame = CGRect(x: 0, y: 0, width: 0, height: newHeight)

// Invalidate intrinsic content size to trigger a layout update
invalidateIntrinsicContentSize()
layoutIfNeeded()
}

public func hide() {
guard isShown else { return }
isShown = false
print("hide")
updateHeight(to: 0.0)
superview?.layoutIfNeeded()
}

override public var intrinsicContentSize: CGSize {
return CGSize(width: UIView.noIntrinsicMetric, height: frame.height)
}

private func setupView() {
isUserInteractionEnabled = false
// TODO: Set the background color to transparent

Check failure on line 55 in ios/interactive/InvisibleInputAccessoryView.swift

View workflow job for this annotation

GitHub Actions / 🔎 Swift Lint

Todo Violation: TODOs should be resolved (Set the background color to tr...) (todo)
// backgroundColor = UIColor.red.withAlphaComponent(0.2)
backgroundColor = .clear
autoresizingMask = .flexibleHeight
}
}
68 changes: 68 additions & 0 deletions ios/interactive/KeyboardAreaExtender.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//
// KeyboardAreaExtender.swift
// Pods
//
// Created by Kiryl Ziusko on 02/11/2024.
//

class KeyboardAreaExtender: NSObject {
private var currentInputAccessoryView: InvisibleInputAccessoryView?

@objc public static let shared = KeyboardAreaExtender()

override private init() {
super.init()
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardDidAppear),
name: UIResponder.keyboardDidShowNotification,
object: nil
)
}

deinit {
NotificationCenter.default.removeObserver(self)
}

public var offset: CGFloat {
return currentInputAccessoryView?.frame.height ?? 0
}

public func hide() {
if (currentInputAccessoryView?.isShown ?? false) {

Check failure on line 32 in ios/interactive/KeyboardAreaExtender.swift

View workflow job for this annotation

GitHub Actions / 🔎 Swift Lint

Control Statement Violation: `if`, `for`, `guard`, `switch`, `while`, and `catch` statements shouldn't unnecessarily wrap their conditionals or arguments in parentheses (control_statement)
print("hide iav")
NotificationCenter.default.post(
name: .shouldIgnoreKeyboardEvents, object: nil, userInfo: ["ignore": true]
)
currentInputAccessoryView?.hide()
}
print("ignore hide")
}

public func updateHeight(_ to: CGFloat, _ nativeID: String) {

Check failure on line 42 in ios/interactive/KeyboardAreaExtender.swift

View workflow job for this annotation

GitHub Actions / 🔎 Swift Lint

Identifier Name Violation: Variable name 'to' should be between 3 and 40 characters long (identifier_name)
if UIResponder.current.nativeID == nativeID {
currentInputAccessoryView?.updateHeight(to: to)
}
}

@objc private func keyboardDidAppear(_: Notification) {
print("KEA - keyboardDidAppear \(Date.currentTimeStamp)")
let responder = UIResponder.current
if let activeTextInput = responder as? TextInput,
let offset = KeyboardOffsetProvider.shared.getOffset(
forTextInputNativeID: responder.nativeID),
responder?.inputAccessoryView == nil
{
currentInputAccessoryView = InvisibleInputAccessoryView(height: CGFloat(truncating: offset))

activeTextInput.inputAccessoryView = currentInputAccessoryView
activeTextInput.reloadInputViews()

NotificationCenter.default.post(
name: .shouldIgnoreKeyboardEvents, object: nil, userInfo: ["ignore": true]
)

print("Attaching `inputAccessoryView` \(Date.currentTimeStamp)")
}
}
}
36 changes: 36 additions & 0 deletions ios/interactive/KeyboardOffsetProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// KeyboardOffsetProvider.swift
// Pods
//
// Created by Kiryl Ziusko on 01/11/2024.
//

import Foundation

@objc(KeyboardOffsetProvider)
public class KeyboardOffsetProvider: NSObject {
@objc public static let shared = KeyboardOffsetProvider()

override private init() {}

private var offsetMap: [String: NSNumber] = [:]

@objc public func setOffset(forTextInputNativeID nativeID: String, offset: NSNumber) {
KeyboardAreaExtender.shared.updateHeight(CGFloat(offset), nativeID)
offsetMap[nativeID] = offset
}

@objc public func getOffset(forTextInputNativeID nativeID: String?) -> NSNumber? {
guard let unwrappedNativeID = nativeID else { return nil }
return offsetMap[unwrappedNativeID]
}

@objc public func hasOffset(forTextInputNativeID nativeID: String?) -> Bool {
guard let unwrappedNativeID = nativeID else { return false }
return offsetMap[unwrappedNativeID] != nil
}

@objc public func removeOffset(forTextInputNativeID nativeID: String) {
offsetMap.removeValue(forKey: nativeID)
}
}
Loading
Loading