Skip to content

Commit

Permalink
feat: own useWindowDimensions hook (#468)
Browse files Browse the repository at this point in the history
## 📜 Description

Implemented own `useWindowDimensions` hook for Android.

## 💡 Motivation and Context

The problem with default implementation of `useWindowDimensions` on
Android is that it doesn't work well with `edge-to-edge` mode and you
can not retrieve an actual size of the screen. Here is a brief
comparison of values captured on my device (Pixel 7 Pro).

Translucent `StatusBar`:

```sh
height: 867.4285888671875 <- own hook
height: 867.4285888671875, y: 0 <- useSafeAreaFrame
height: 891.4285714285714 <- Dimensions.get('screen')
height: 826.2857142857143 <- Dimensions.get('window')
```

Non translucent `StatusBar`:

```sh
height: 867.4285888671875 <- own hook
height: 826.2857055664062, y: 41.14285659790039 <- useSafeAreaFrame
height: 891.4285714285714 <- Dimensions.get('screen')
height: 826.2857142857143 <- Dimensions.get('window')
```

So as you can see it doesn't react properly on the case when `StatusBar`
is translucent and reports incorrect values, which later on causes
incorrect layout calculation in components like `KeyboardAvoidingView`
or `KeyboardAwareScrollView`.

Theoretically we could workaround this problem by original
`useWindowDimensions().height + StatusBar.currentHeight`, but everything
become trickier when we add translucent `navigationBar` (+ translucent
`statusBar`):

```sh
height: 891.4285888671875 <- own hook
height: 891.4285888671875, y: 0 <- useSafeAreaFrame
height: 891.4285714285714 <- Dimensions.get('screen')
height: 826.2857142857143 <- Dimensions.get('window')
```

In this case derived value `useWindowDimensions().height +
StatusBar.currentHeight` (867.4285888671875) still will produce
incorrect value and all calculations will be broken. So I decided to
create own version of the hook which will cover all the cases.

Issue for reference:
facebook/react-native#41918

Closes
#434
#334

## 📢 Changelog

<!-- High level overview of important changes -->
<!-- For example: fixed status bar manipulation; added new types
declarations; -->
<!-- If your changes don't affect one of platform/language below - then
remove this platform/language -->

### JS

- export own `useWindowDimensions` hook;
- started to use own `useWindowDimensions` in `KeyboardAwareScrollView`
and `KeyboardAvoidingView` components;
- added mock for `useWindowDimensions` hook as default RN
implementation;

### Android

- added `WindowDimensionsListener` class;
- added `ThemedReactContext.content` extension;
- added `ThemedReactContext.setupWindowDimensionListener` extension;

## 🤔 How Has This Been Tested?

Tested manually on Pixel 7 Pro (API 34).

Tested on CI via e2e (API 28).

## 📸 Screenshots (if appropriate):

Pixel 7 Pro (Android 14), `KeyboardAwareScrollView`:

### KeyboardAwareScrollView

|Before|After|
|-------|-----|

|![telegram-cloud-photo-size-2-5429580266013318443-y](https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/4874f962-2726-4cd0-ba96-4b57c076b6f5)|![telegram-cloud-photo-size-2-5429580266013318444-y](https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/28a3b276-f8b7-40fa-a17d-bcc4e58b28b7)|

### KeyboardAvoidingView

|Initial|Before|After|
|------|------|-----|

|![telegram-cloud-photo-size-2-5429580266013318470-y](https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/570d0092-c846-4005-97b0-c596169b91f8)|![telegram-cloud-photo-size-2-5429580266013318469-y](https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/8909fee6-aa42-45a3-87b6-65c59831c703)|![telegram-cloud-photo-size-2-5429580266013318471-y](https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/e64c5dfd-cdc1-4cf5-b731-3a25d3a2fde3)|

## 📝 Checklist

- [x] CI successfully passed
- [x] I added new mocks and corresponding unit-tests if library API was
changed
  • Loading branch information
kirillzyusko committed Jun 13, 2024
1 parent 97d504d commit 817127f
Show file tree
Hide file tree
Showing 12 changed files with 146 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,26 @@ package com.reactnativekeyboardcontroller.extensions

import android.util.Log
import android.view.View
import android.view.ViewGroup
import com.facebook.react.bridge.WritableMap
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.reactnativekeyboardcontroller.listeners.WindowDimensionListener

val ThemedReactContext.rootView: View?
get() = this.currentActivity?.window?.decorView?.rootView

val ThemedReactContext.content: ViewGroup?
get() = this.currentActivity?.window?.decorView?.rootView?.findViewById(
androidx.appcompat.R.id.action_bar_root,
)

fun ThemedReactContext.setupWindowDimensionsListener() {
WindowDimensionListener(this)
}

fun ThemedReactContext?.dispatchEvent(viewId: Int, event: Event<*>) {
val eventDispatcher: EventDispatcher? =
UIManagerHelper.getEventDispatcherForReactTag(this, viewId)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.reactnativekeyboardcontroller.listeners

import android.view.ViewGroup
import androidx.core.view.marginTop
import com.facebook.react.bridge.Arguments
import com.facebook.react.uimanager.ThemedReactContext
import com.reactnativekeyboardcontroller.extensions.content
import com.reactnativekeyboardcontroller.extensions.dp
import com.reactnativekeyboardcontroller.extensions.emitEvent

data class Dimensions(val width: Double, val height: Double)

class WindowDimensionListener(private val context: ThemedReactContext?) {
private var lastDispatchedDimensions = Dimensions(0.0, 0.0)

init {
// attach to content view only once
if (!isListenerAttached) {
isListenerAttached = true

val content = context?.content

updateWindowDimensions(content)

content?.viewTreeObserver?.addOnGlobalLayoutListener {
updateWindowDimensions(content)
}
}
}

private fun updateWindowDimensions(content: ViewGroup?) {
if (content == null) {
return
}

val newDimensions = Dimensions(
content.width.toFloat().dp,
content.height.toFloat().dp + content.marginTop.toFloat().dp,
)

if (newDimensions != lastDispatchedDimensions) {
lastDispatchedDimensions = newDimensions

context.emitEvent(
"KeyboardController::windowDidResize",
Arguments.createMap().apply {
putDouble("height", newDimensions.height)
putDouble("width", newDimensions.width)
},
)
}
}

companion object {
private var isListenerAttached = false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.FrameLayout
import androidx.appcompat.widget.FitWindowsLinearLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsAnimationCompat
import androidx.core.view.WindowInsetsCompat
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.view.ReactViewGroup
import com.reactnativekeyboardcontroller.extensions.content
import com.reactnativekeyboardcontroller.extensions.removeSelf
import com.reactnativekeyboardcontroller.extensions.requestApplyInsetsWhenAttached
import com.reactnativekeyboardcontroller.extensions.rootView
import com.reactnativekeyboardcontroller.extensions.setupWindowDimensionsListener
import com.reactnativekeyboardcontroller.listeners.KeyboardAnimationCallback

private val TAG = EdgeToEdgeReactViewGroup::class.qualifiedName
Expand All @@ -32,6 +33,10 @@ class EdgeToEdgeReactViewGroup(private val reactContext: ThemedReactContext) : R
private var wasMounted = false
private var callback: KeyboardAnimationCallback? = null

init {
reactContext.setupWindowDimensionsListener()
}

// region View life cycles
override fun onAttachedToWindow() {
super.onAttachedToWindow()
Expand All @@ -57,7 +62,7 @@ class EdgeToEdgeReactViewGroup(private val reactContext: ThemedReactContext) : R
val rootView = reactContext.rootView
if (rootView != null) {
ViewCompat.setOnApplyWindowInsetsListener(rootView) { v, insets ->
val content = getContentView()
val content = reactContext.content
val params = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
Expand Down Expand Up @@ -112,7 +117,7 @@ class EdgeToEdgeReactViewGroup(private val reactContext: ThemedReactContext) : R

if (activity != null) {
eventView = ReactViewGroup(context)
val root = this.getContentView()
val root = reactContext.content
root?.addView(eventView)

callback = KeyboardAnimationCallback(
Expand Down Expand Up @@ -148,12 +153,6 @@ class EdgeToEdgeReactViewGroup(private val reactContext: ThemedReactContext) : R
// for more details
Handler(Looper.getMainLooper()).post { view.removeSelf() }
}

private fun getContentView(): FitWindowsLinearLayout? {
return reactContext.currentActivity?.window?.decorView?.rootView?.findViewById(
androidx.appcompat.R.id.action_bar_root,
)
}
// endregion

// region State managers
Expand Down
4 changes: 3 additions & 1 deletion jest/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Animated, ScrollView, View } from "react-native";
import { Animated, ScrollView, View, useWindowDimensions } from "react-native";

const values = {
animated: {
Expand Down Expand Up @@ -43,6 +43,8 @@ const mock = {
useKeyboardController: jest
.fn()
.mockReturnValue({ setEnabled: jest.fn(), enabled: true }),
// internal
useWindowDimensions,
// modules
KeyboardController: {
setInputMode: jest.fn(),
Expand Down
5 changes: 5 additions & 0 deletions src/bindings.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
KeyboardControllerProps,
KeyboardEventsModule,
KeyboardGestureAreaProps,
WindowDimensionsEventsModule,
} from "./types";

const LINKING_ERROR =
Expand Down Expand Up @@ -44,6 +45,10 @@ export const FocusedInputEvents: FocusedInputEventsModule = {
addListener: (name, cb) =>
eventEmitter.addListener(KEYBOARD_CONTROLLER_NAMESPACE + name, cb),
};
export const WindowDimensionsEvents: WindowDimensionsEventsModule = {
addListener: (name, cb) =>
eventEmitter.addListener(KEYBOARD_CONTROLLER_NAMESPACE + name, cb),
};
export const KeyboardControllerView: React.FC<KeyboardControllerProps> =
require("./specs/KeyboardControllerViewNativeComponent").default;
export const KeyboardGestureArea: React.FC<KeyboardGestureAreaProps> =
Expand Down
4 changes: 4 additions & 0 deletions src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
KeyboardControllerProps,
KeyboardEventsModule,
KeyboardGestureAreaProps,
WindowDimensionsEventsModule,
} from "./types";
import type { EmitterSubscription } from "react-native";

Expand All @@ -28,6 +29,9 @@ export const KeyboardEvents: KeyboardEventsModule = {
export const FocusedInputEvents: FocusedInputEventsModule = {
addListener: () => ({ remove: NOOP } as EmitterSubscription),
};
export const WindowDimensionsEvents: WindowDimensionsEventsModule = {
addListener: () => ({ remove: NOOP } as EmitterSubscription),
};
export const KeyboardControllerView =
View as unknown as React.FC<KeyboardControllerProps>;
export const KeyboardGestureArea =
Expand Down
4 changes: 3 additions & 1 deletion src/components/KeyboardAvoidingView/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { forwardRef, useCallback, useMemo } from "react";
import { View, useWindowDimensions } from "react-native";
import { View } from "react-native";
import Reanimated, {
interpolate,
runOnUI,
Expand All @@ -8,6 +8,8 @@ import Reanimated, {
useSharedValue,
} from "react-native-reanimated";

import { useWindowDimensions } from "react-native-keyboard-controller";

import { useKeyboardAnimation } from "./hooks";

import type { LayoutRectangle, ViewProps } from "react-native";
Expand Down
3 changes: 2 additions & 1 deletion src/components/KeyboardAwareScrollView/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { forwardRef, useCallback, useMemo } from "react";
import { findNodeHandle, useWindowDimensions } from "react-native";
import { findNodeHandle } from "react-native";
import Reanimated, {
interpolate,
scrollTo,
Expand All @@ -12,6 +12,7 @@ import Reanimated, {
import {
useFocusedInputHandler,
useReanimatedFocusedInput,
useWindowDimensions,
} from "react-native-keyboard-controller";

import { useSmoothKeyboardHandler } from "./useSmoothKeyboardHandler";
Expand Down
14 changes: 8 additions & 6 deletions src/hooks.ts → src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { useEffect } from "react";

import { KeyboardController } from "./bindings";
import { AndroidSoftInputModes } from "./constants";
import { useKeyboardContext } from "./context";
import { uuid } from "./utils";
import { KeyboardController } from "../bindings";
import { AndroidSoftInputModes } from "../constants";
import { useKeyboardContext } from "../context";
import { uuid } from "../utils";

import type { AnimatedContext, ReanimatedContext } from "./context";
import type { FocusedInputHandler, KeyboardHandler } from "./types";
import type { AnimatedContext, ReanimatedContext } from "../context";
import type { FocusedInputHandler, KeyboardHandler } from "../types";
import type { DependencyList } from "react";

export const useResizeMode = () => {
Expand Down Expand Up @@ -86,3 +86,5 @@ export function useFocusedInputHandler(
};
}, deps);
}

export * from "./useWindowDimensions";
33 changes: 33 additions & 0 deletions src/hooks/useWindowDimensions/index.android.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";

import { WindowDimensionsEvents } from "../../bindings";

import type { WindowDimensionsEventData } from "../../types";

let initialDimensions: WindowDimensionsEventData = {
width: 0,
height: 0,
};

WindowDimensionsEvents.addListener("windowDidResize", (e) => {
initialDimensions = e;
});

export const useWindowDimensions = () => {
const [dimensions, setDimensions] = useState(initialDimensions);

useEffect(() => {
const subscription = WindowDimensionsEvents.addListener(
"windowDidResize",
(e) => {
setDimensions(e);
},
);

return () => {
subscription.remove();
};
}, []);

return dimensions;
};
1 change: 1 addition & 0 deletions src/hooks/useWindowDimensions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useWindowDimensions } from "react-native";
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,17 @@ export type FocusedInputEventsModule = {
cb: (e: FocusedInputEventData) => void,
) => EmitterSubscription;
};
export type WindowDimensionsAvailableEvents = "windowDidResize";
export type WindowDimensionsEventData = {
width: number;
height: number;
};
export type WindowDimensionsEventsModule = {
addListener: (
name: WindowDimensionsAvailableEvents,
cb: (e: WindowDimensionsEventData) => void,
) => EmitterSubscription;
};

// reanimated hook declaration
export type KeyboardHandlerHook<TContext, Event> = (
Expand Down

0 comments on commit 817127f

Please sign in to comment.