Skip to content

Commit

Permalink
feat: target (#191)
Browse files Browse the repository at this point in the history
## 馃摐 Description

Added `target` field which represent a `viewTag` of focused `TextInput`
field.

## 馃挕 Motivation and Context

If we know tag of focused field we can apply advanced techniques and
require additional information on demand (for example measure layout and
calculate the scroll amount).

I had a lot of designs on how to implement this feature. The main
tradeoff was about whether this information should be provided as
**separate hook** or should it be included as **part of keyboard event
metadata**.

For simplicity purpose I've decided to deliver this information as part
of keyboard events (because there appears a lot of problems with
synchronization values between various hooks - you'll need to assure
that one variables are updated before other or run something in
effects/reactions and it can make your code more difficult for
understanding). So for now I'll deliver all information via already
existing hooks 馃檪

Another important aspect is the fact, that information about focused
field is deliver differently per different OS versions. On Android it's
done via `addOnGlobalFocusChangeListener`, on iOS you can derive this
information from keyboard lifecycles. And it appears to be a problem,
because iOS uses kind of one channel for communication (keyboard events)
- so even if you've changed focus and keyboard didn't change its
size/position, it still will trigger a keyboard event (and in this
keyboard event you can get a new info about focused field). Android
works differently - it provides a callback which will be fired, when
focus has changed (and this callback will be always called before any
keyboard movement).

So taking the information above into consideration I've decided to
update internal variable in `addOnGlobalFocusChangeListener` since
keyboard events will be fired later and tag values can be packed into
event metadata. The only one excluding case is when keyboard is already
shown and focus has been changed - in this case we dispatch the same
events instantly, because we don't know in advance whether keyboard size
will be changed (i. e. whether onStart/onMove/onEnd will be dispatched).
So we dispatch them anyway and later, if keyboard size is changed, we
will dispatch plain events. From my perspective it'll not introduce any
breaking changes.

Last but not least - on Paper architecture you can pass `() => tag` or
use `UIManager.measure(tag, ...` to measure layout. But on Fabric
architecture you only can measure layout by passing `ShadowNode` instead
of `tag`. However you still can get `ShadowNode` and `tag` in Fabric
from `ref`, so you can use mapping:

```ts
{
  // paper
  [tag]: () => tag
  // fabric
  [tag]: () => ShadowNode
}
```

I'll use this concept later in updated version of AwareScrollView
example.

Maybe after investigating more edge cases I'll come up with a different
solution, but for now providing `target` opens new horizons and I'm
going to explore them soon 馃槑

Closes
#163

## 馃摙 Changelog

### JS
- added `target` field to plain and worklet events;

### iOS
- added variable `KEYBOARD_CONTROLLER_NEW_ARCH_ENABLED` in Podfile to
write architecture specific code in Swift files;
- added `UIResponder` extension which allows to get focused TextInput on
demand;
- added extension for `reactViewTag` which returns tag of focused
`TextInput`;
- detect tag in keyboard lifecycles and reuse it in `KVO` and
`DisplayLink` to improve performance.

### Android
- added `addOnGlobalFocusChangeListener` to detect a focused
`TextInput`;
- dispatch instant (start/end) events only when keyboard is visible and
focus has been changed;

## 馃 How Has This Been Tested?

Tested both fabric/paper example apps on:
- iPhone 14 Pro (iOS 16.5, simulator);
- Pixel 7 Pro (Android 13, real device);

## 馃摳 Screenshots (if appropriate):

<img width="570" alt="image"
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/5fd0af7f-b953-4c33-8a7a-f85e3e049f23">

## 馃摑 Checklist

- [x] CI successfully passed
  • Loading branch information
kirillzyusko committed Jul 17, 2023
1 parent 43ddde7 commit 0d6bd83
Show file tree
Hide file tree
Showing 13 changed files with 119 additions and 27 deletions.
2 changes: 1 addition & 1 deletion FabricExample/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1056,7 +1056,7 @@ SPEC CHECKSUMS:
React-jsiexecutor: 18b5b33c5f2687a784a61bc8176611b73524ae77
React-jsinspector: b6ed4cb3ffa27a041cd440300503dc512b761450
React-logger: 186dd536128ae5924bc38ed70932c00aa740cd5b
react-native-keyboard-controller: b2e128c0ed487af2c6d2847c37ab9507ddbfae1e
react-native-keyboard-controller: 9c3f981fc70fffb5371eb70cb3a091f024f576af
react-native-safe-area-context: e7e7c502560f89a6a1866af293d1e091f3c7929d
React-perflogger: e706562ab7eb8eb590aa83a224d26fa13963d7f2
React-RCTActionSheet: 57d4bd98122f557479a3359ad5dad8e109e20c5a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.Event
import com.facebook.react.uimanager.events.EventDispatcher
import com.facebook.react.views.textinput.ReactEditText
import com.facebook.react.views.view.ReactViewGroup
import com.reactnativekeyboardcontroller.events.KeyboardTransitionEvent
import com.reactnativekeyboardcontroller.extensions.dp
Expand All @@ -35,12 +36,31 @@ class KeyboardAnimationCallback(
private var isKeyboardVisible = false
private var isTransitioning = false
private var duration = 0
private var viewTagFocused = -1

init {
require(persistentInsetTypes and deferredInsetTypes == 0) {
"persistentInsetTypes and deferredInsetTypes can not contain any of " +
" same WindowInsetsCompat.Type values"
}

view.viewTreeObserver.addOnGlobalFocusChangeListener { oldFocus, newFocus ->
if (newFocus is ReactEditText) {
viewTagFocused = newFocus.id

// keyboard is visible and focus has been changed
if (this.isKeyboardVisible && oldFocus !== null) {
// imitate iOS behavior and send two instant start/end events containing an info about new tag
// 1. onStart/onMove/onEnd can be still dispatched after, if keyboard change size (numeric -> alphabetic type)
// 2. event should be send only when keyboard is visible, since this event arrives earlier -> `tag` will be 100% included in
// onStart/onMove/onEnd lifecycles, but triggering onStart/onEnd several time can bring breaking changes
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMoveStart", this.persistentKeyboardHeight, 1.0, 0, viewTagFocused))
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMoveEnd", this.persistentKeyboardHeight, 1.0, 0, viewTagFocused))
this.emitEvent("KeyboardController::keyboardWillShow", getEventParams(this.persistentKeyboardHeight))
this.emitEvent("KeyboardController::keyboardDidShow", getEventParams(this.persistentKeyboardHeight))
}
}
}
}

/**
Expand Down Expand Up @@ -70,16 +90,16 @@ class KeyboardAnimationCallback(
val duration = durationL.toInt()

this.emitEvent("KeyboardController::keyboardWillShow", getEventParams(keyboardHeight))
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMoveStart", keyboardHeight, 1.0, duration))
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMoveStart", keyboardHeight, 1.0, duration, viewTagFocused))

val animation = ValueAnimator.ofFloat(this.persistentKeyboardHeight.toFloat(), keyboardHeight.toFloat())
animation.addUpdateListener { animator ->
val toValue = animator.animatedValue as Float
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMove", toValue.toDouble(), toValue.toDouble() / keyboardHeight, duration))
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMove", toValue.toDouble(), toValue.toDouble() / keyboardHeight, duration, viewTagFocused))
}
animation.doOnEnd {
this.emitEvent("KeyboardController::keyboardDidShow", getEventParams(keyboardHeight))
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMoveEnd", keyboardHeight, 1.0, duration))
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMoveEnd", keyboardHeight, 1.0, duration, viewTagFocused))
}
animation.setDuration(durationL).startDelay = 0
animation.start()
Expand All @@ -106,8 +126,8 @@ class KeyboardAnimationCallback(

this.emitEvent("KeyboardController::" + if (!isKeyboardVisible) "keyboardWillHide" else "keyboardWillShow", getEventParams(keyboardHeight))

Log.i(TAG, "HEIGHT:: $keyboardHeight")
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMoveStart", keyboardHeight, if (!isKeyboardVisible) 0.0 else 1.0, duration))
Log.i(TAG, "HEIGHT:: $keyboardHeight TAG:: $viewTagFocused")
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMoveStart", keyboardHeight, if (!isKeyboardVisible) 0.0 else 1.0, duration, viewTagFocused))

return super.onStart(animation, bounds)
}
Expand Down Expand Up @@ -137,10 +157,10 @@ class KeyboardAnimationCallback(
} catch (e: ArithmeticException) {
// do nothing, send progress as 0
}
Log.i(TAG, "DiffY: $diffY $height $progress ${InteractiveKeyboardProvider.isInteractive}")
Log.i(TAG, "DiffY: $diffY $height $progress ${InteractiveKeyboardProvider.isInteractive} $viewTagFocused")

val event = if (InteractiveKeyboardProvider.isInteractive) "topKeyboardMoveInteractive" else "topKeyboardMove"
this.sendEventToJS(KeyboardTransitionEvent(view.id, event, height, progress, duration))
this.sendEventToJS(KeyboardTransitionEvent(view.id, event, height, progress, duration, viewTagFocused))

return insets
}
Expand All @@ -166,7 +186,7 @@ class KeyboardAnimationCallback(
isKeyboardVisible = isKeyboardVisible || isKeyboardShown

this.emitEvent("KeyboardController::" + if (!isKeyboardVisible) "keyboardDidHide" else "keyboardDidShow", getEventParams(keyboardHeight))
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMoveEnd", keyboardHeight, if (!isKeyboardVisible) 0.0 else 1.0, duration))
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMoveEnd", keyboardHeight, if (!isKeyboardVisible) 0.0 else 1.0, duration, viewTagFocused))

// reset to initial state
duration = 0
Expand Down Expand Up @@ -204,6 +224,7 @@ class KeyboardAnimationCallback(
params.putDouble("height", height)
params.putInt("duration", duration)
params.putDouble("timestamp", System.currentTimeMillis().toDouble())
params.putInt("target", viewTagFocused)

return params
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class KeyboardTransitionEvent(
private val height: Double,
private val progress: Double,
private val duration: Int,
private val target: Int,
) : Event<KeyboardTransitionEvent>(viewId) {
override fun getEventName() = event

Expand All @@ -21,6 +22,7 @@ class KeyboardTransitionEvent(
map.putDouble("progress", progress)
map.putDouble("height", height)
map.putInt("duration", duration)
map.putInt("target", target)
rctEventEmitter.receiveEvent(viewTag, eventName, map)
}
}
25 changes: 25 additions & 0 deletions ios/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//

import Foundation
import UIKit

public extension CGFloat {
static func interpolate(inputRange: [CGFloat], outputRange: [CGFloat], currentValue: CGFloat) -> CGFloat {
Expand All @@ -27,3 +28,27 @@ public extension Date {
return Int64(Date().timeIntervalSince1970 * 1000)
}
}

public extension UIResponder {
private weak static var _currentFirstResponder: UIResponder?

static var current: UIResponder? {
UIResponder._currentFirstResponder = nil
UIApplication.shared.sendAction(#selector(findFirstResponder(sender:)), to: nil, from: nil, for: nil)
return UIResponder._currentFirstResponder
}

@objc internal func findFirstResponder(sender _: AnyObject) {
UIResponder._currentFirstResponder = self
}
}

public extension Optional where Wrapped == UIResponder {
var reactViewTag: NSNumber {
#if KEYBOARD_CONTROLLER_NEW_ARCH_ENABLED
return ((self as? RCTUITextField)?.superview?.tag ?? -1) as NSNumber
#else
return (self as? RCTUITextField)?.superview?.reactTag ?? -1
#endif
}
}
1 change: 1 addition & 0 deletions ios/KeyboardController-Bridging-Header.h
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#import <React/RCTUITextField.h>
#import <React/RCTViewManager.h>
21 changes: 15 additions & 6 deletions ios/KeyboardControllerView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ - (instancetype)initWithFrame:(CGRect)frame

observer = [[KeyboardMovementObserver alloc]
initWithHandler:^(
NSString *event, NSNumber *height, NSNumber *progress, NSNumber *duration) {
NSString *event,
NSNumber *height,
NSNumber *progress,
NSNumber *duration,
NSNumber *target) {
if (self->_eventEmitter) {
// TODO: use reflection to reduce code duplication?
if ([event isEqualToString:@"onKeyboardMoveStart"]) {
Expand All @@ -55,7 +59,8 @@ - (instancetype)initWithFrame:(CGRect)frame
facebook::react::KeyboardControllerViewEventEmitter::OnKeyboardMoveStart{
.height = [height doubleValue],
.progress = [progress doubleValue],
.duration = [duration intValue]});
.duration = [duration intValue],
.target = [target intValue]});
}
if ([event isEqualToString:@"onKeyboardMove"]) {
std::dynamic_pointer_cast<const facebook::react::KeyboardControllerViewEventEmitter>(
Expand All @@ -64,7 +69,8 @@ - (instancetype)initWithFrame:(CGRect)frame
facebook::react::KeyboardControllerViewEventEmitter::OnKeyboardMove{
.height = [height doubleValue],
.progress = [progress doubleValue],
.duration = [duration intValue]});
.duration = [duration intValue],
.target = [target intValue]});
}
if ([event isEqualToString:@"onKeyboardMoveEnd"]) {
std::dynamic_pointer_cast<const facebook::react::KeyboardControllerViewEventEmitter>(
Expand All @@ -73,7 +79,8 @@ - (instancetype)initWithFrame:(CGRect)frame
facebook::react::KeyboardControllerViewEventEmitter::OnKeyboardMoveEnd{
.height = [height doubleValue],
.progress = [progress doubleValue],
.duration = [duration intValue]});
.duration = [duration intValue],
.target = [target intValue]});
}
}
if ([event isEqualToString:@"onKeyboardMoveInteractive"]) {
Expand All @@ -83,7 +90,8 @@ - (instancetype)initWithFrame:(CGRect)frame
facebook::react::KeyboardControllerViewEventEmitter::OnKeyboardMoveInteractive{
.height = [height doubleValue],
.progress = [progress doubleValue],
.duration = [duration intValue]});
.duration = [duration intValue],
.target = [target intValue]});
}

// TODO: use built-in _eventEmitter once NativeAnimated module will use ModernEventemitter
Expand All @@ -94,7 +102,8 @@ - (instancetype)initWithFrame:(CGRect)frame
event:event
height:height
progress:progress
duration:duration];
duration:duration
target:target];
[bridge.eventDispatcher sendEvent:keyboardMoveEvent];
}
}
Expand Down
5 changes: 3 additions & 2 deletions ios/KeyboardControllerViewManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class KeyboardControllerView: UIView {
}
}

func onEvent(event: NSString, height: NSNumber, progress: NSNumber, duration: NSNumber) {
func onEvent(event: NSString, height: NSNumber, progress: NSNumber, duration: NSNumber, target: NSNumber) {
// we don't want to send event to JS before the JS thread is ready
if bridge.value(forKey: "_jsThread") == nil {
return
Expand All @@ -57,7 +57,8 @@ class KeyboardControllerView: UIView {
event: event as String,
height: height,
progress: progress,
duration: duration
duration: duration,
target: target
)
)
}
Expand Down
3 changes: 2 additions & 1 deletion ios/KeyboardMoveEvent.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
event:(NSString *)event
height:(NSNumber *)height
progress:(NSNumber *)progress
duration:(NSNumber *)duration;
duration:(NSNumber *)duration
target:(NSNumber *)target;

@end
4 changes: 4 additions & 0 deletions ios/KeyboardMoveEvent.m
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ @implementation KeyboardMoveEvent {
NSNumber *_progress;
NSNumber *_height;
NSNumber *_duration;
NSNumber *_target;
uint16_t _coalescingKey;
}

Expand All @@ -24,6 +25,7 @@ - (instancetype)initWithReactTag:(NSNumber *)reactTag
height:(NSNumber *)height
progress:(NSNumber *)progress
duration:(NSNumber *)duration
target:(NSNumber *)target
{
RCTAssertParam(reactTag);

Expand All @@ -33,6 +35,7 @@ - (instancetype)initWithReactTag:(NSNumber *)reactTag
_progress = progress;
_height = height;
_duration = duration;
_target = target;
_coalescingKey = 0;
}
return self;
Expand All @@ -51,6 +54,7 @@ - (NSDictionary *)body
@"progress" : _progress,
@"height" : _height,
@"duration" : _duration,
@"target" : _target,
};

return body;
Expand Down
Loading

0 comments on commit 0d6bd83

Please sign in to comment.