mattermost-mobile/app/hooks/keyboardAnimation.ts
Rajat Dabade e39b27be7e
New keyboard library Setup (#9278)
* Initial setup for new keyboard

* fix the offset calculation onMove by adding isKeyboardFullyOpened

* Done with the keyboard handling implementation

* Handled keyboard focus and blured state using context

* Added default height for input container

* Android support

* Tablet state handling

* Fix for refreshing offset in list

* Created a default context for mention post list

* Fix linter errors

* Fix tests

* Minor

* Fix the height issue for tablet view

* Review comments

* Dependency fix

* Reveiw comment

* keyboard animation only enabled with screen on top navigation

* added tests

* Added extra keyboard component for emoji picker (#9328)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Check for stale event for keyboard geature area

* fix keyboard stale event for mid-gesture change swipe direction

* Closing emoji picker when message priority is opened

* changing to thread view closes emoji picker

* Close emoij picker and keyboard when attachment icons are clicked

* handle android emoji picker behaviour

* Remove emoji picker code

* fix tests

* Address reviev comments

* Test fixes

* usePreventDoubleTab for race condition and corrected comment on isInputAccessoryViewMode flag

* File attachments option in bottom sheet quick action (#9331)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* File attachments option in bottom sheet quick action

* Updated tests

* i18n

* fix test

* Added tests

* Review comment

* fix tests

* Integrated Emoji picker to Extra keyboard component.  (#9339)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Fix the post input container height change issue

* Added emoji picker with search functionality

* keyboard height not recorded fallback to default height, set search to false closing emoji picker

* fix search funcitonality in android

* scroll to end bottom dismissed with pressed

* Fixed the scroll issue for android

* fix the flickering post input issue

* Wired up the emoji picker

* Added keyboard and spaceback in emoji picker

* intl extract

* initial cursor position and simple calculation review comment

* separated grapheme to utils file

* Review comments

* nitpick

* Fix the bottom safe area and navigation stack restore fix

* Fix test

* disabled emoji picker in edit post screen

* change the name of the variable to name it clarier

* fix the tab gap issue in andriod

* disabled the emoji skin tone change on andriod

* fix the input container jump issue

* fix the failing test

* UX review

* added white space after the attachment icon

* When @ or / is press open keyboard on andriod also fix the scroll issue when keyboard is open

* back bottom closes emoji picker and opening keyboard jump fix

* reverted code

* fixed the flickering issue of input and scroll position fix

* Fix the gap issue in thread

* fix the warning reaminated issue when opening a channel

* Fix the extra space issue between input and emoij picker

* Minor fix for extra space between input and emoji picker

* Fix thread view bottom safe area and gap between keyboard and searched emojis

* Fix the keyboard issue in tablet

* Fix the warning issue react-native-keyboard-controller

* Fix tests

* Fix the tablet search hight issue

* Replaced the ExtraKeyboardProvider with KeyboardProvider from rnkc for permalink

* Bottom sheet jump issue resolved

* Search do not close after selecting emoji in search list

* Added the bottom inset height in search emoji for android

* fix buid issue

* Fix the cancel state to emoji picker

* use portals for autocomplete

* fix permalink extra space in post list

* Portal only for android

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Rahim Rahman <rahim.rahman@mattermost.com>
Co-authored-by: Harshil Sharma <harshilsharma63@gmail.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2026-01-13 22:38:35 +05:30

525 lines
24 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useKeyboardHandler} from 'react-native-keyboard-controller';
import {useAnimatedScrollHandler, useDerivedValue, useSharedValue} from 'react-native-reanimated';
import {BOTTOM_TAB_HEIGHT} from '@constants/view';
export const useKeyboardAnimation = (
postInputContainerHeight: number,
enableAnimation = true,
isTablet = false,
safeAreaBottom = 0,
isThreadView = false,
enabled = true,
) => {
/**
* progress: Keyboard animation progress (0 = closed, 1 = fully open)
* Used for: Tracking keyboard animation state
*/
const progress = useSharedValue(0);
/**
* keyboardTranslateY: Keyboard height (adjusted for tab bar) used to animate input container position
*
* How it works: This value represents the keyboard height and is used with negative translateY
* in useKeyboardAwarePostDraft.ts (transform: [{translateY: -keyboardTranslateY.value}]) to move the input
* container UP by the keyboard height amount. Higher keyboard height = input moves up more.
*
* Smoothed during interactive gestures to prevent jerky movements
*/
const keyboardTranslateY = useSharedValue(0);
/**
* bottomInset: Bottom inset for the scroll view
* Adds padding at the bottom of scroll content so it doesn't hide behind keyboard
*/
const bottomInset = useSharedValue(0);
/**
* scrollOffset: Scroll offset adjustment when keyboard opens
* Ensures the scroll view scrolls to the right position when keyboard appears
*/
const scrollOffset = useSharedValue(0);
/**
* scroll: Tracks the current scroll position of the ScrollView
* Used to calculate proper offset when keyboard opens
*/
const scrollPosition = useSharedValue(0);
/**
* keyboardHeight: The exact height of the keyboard from events
* Always reflects the true keyboard height without smoothing
*/
const keyboardHeight = useSharedValue(0);
/**
* isKeyboardClosing: Tracks if keyboard is currently closing (detected in onInteractive)
* Used to prevent height jumps in onMove when user releases finger mid-swipe
*/
const isKeyboardClosing = useSharedValue(false);
/**
* isInteractiveGesture: Tracks if we're in an interactive gesture (user touching keyboard)
* Used to: Distinguish between normal keyboard opening vs stale events after gesture
*/
const isInteractiveGesture = useSharedValue(false);
/**
* isKeyboardFullyOpen: True when keyboard is fully open (height > 0 and progress === 1)
*/
const isKeyboardFullyOpen = useSharedValue(false);
/**
* isKeyboardFullyClosed: True when keyboard is fully closed (height === 0)
*/
const isKeyboardFullyClosed = useSharedValue(true);
/**
* isKeyboardInTransition: True when keyboard is animating between open/closed states
*/
const isKeyboardInTransition = useSharedValue(false);
/**
* isEnabled: Shared value to track if keyboard handling is enabled for this screen
* Used to prevent processing keyboard events when screen is not visible
* useDerivedValue automatically tracks the enabled prop and updates reactively
*/
const isEnabled = useDerivedValue(() => enabled, [enabled]);
// Calculate tab bar adjustment (only for tablets, not in thread view)
// This accounts for the tab bar height + safe area bottom that gets hidden when keyboard opens
// Thread views don't have a tab bar, so no adjustment is needed
// For tablets in thread view, also don't subtract safeAreaBottom since the input container
// should move up by the full keyboard height to clear the keyboard completely
let tabBarAdjustment: number;
if (isTablet && !isThreadView) {
// Channel view on tablet: account for tab bar + safe area
tabBarAdjustment = BOTTOM_TAB_HEIGHT + safeAreaBottom;
} else if (isTablet && isThreadView) {
// Thread view on tablet: no adjustment needed (no tab bar, full keyboard height)
tabBarAdjustment = 0;
} else {
// Mobile devices: account for safe area only
tabBarAdjustment = safeAreaBottom;
}
/**
* isInputAccessoryViewMode: Whether we're showing input accessory view (emoji picker) instead of keyboard
* When true, keyboard handlers are ignored to prevent interference with the emoji picker
*/
const isInputAccessoryViewMode = useSharedValue(false);
/**
* isTransitioningFromCustomView: Special mode when transitioning from custom view to keyboard
* Prevents height updates during the transition to avoid jumps
*/
const isTransitioningFromCustomView = useSharedValue(false);
// ------------------------------------------------------------------
// KEYBOARD EVENT HANDLERS
// ------------------------------------------------------------------
/**
* useKeyboardHandler: Subscribe to keyboard lifecycle events
*
* IMPORTANT: All callbacks here are WORKLETS (run on UI thread)
* The "worklet" directive tells Reanimated to compile this function for the UI thread
* This enables 60fps smooth animations without JavaScript thread delays
*
* Event lifecycle:
* 1. onStart: Keyboard animation begins
* 2. onInteractive: User is dragging keyboard interactively
* 3. onMove: Keyboard position changes during animation
* 4. onEnd: Keyboard animation completes
*/
useKeyboardHandler({
/**
* onStart: Called when keyboard animation starts
* @param e - Event object with keyboard information
*/
onStart: (e) => {
'worklet';
// Ignore keyboard events when showing custom view (emoji picker)
if (isInputAccessoryViewMode.value) {
return;
}
// Ignore keyboard events during transition from custom view to keyboard
if (isTransitioningFromCustomView.value) {
return;
}
// Skip processing if screen is not enabled/visible or if animations are disabled
if (!isEnabled.value || !enableAnimation) {
return;
}
// Ignore adjustment event from KeyboardGestureArea (fires after keyboard fully opens)
// After keyboard reaches full height, KeyboardGestureArea sends offset-adjusted event
// We use both offset prop AND manual animation, so this would cause flicker
// Example: keyboard opens at 346px → then adjustment event at 229px (346 - 117 offset)
if (parseInt(e.height.toString()) === keyboardHeight.value - postInputContainerHeight) {
return;
}
progress.value = e.progress;
// Store the exact keyboard height
keyboardHeight.value = e.height;
const adjustedHeight = e.height - (tabBarAdjustment * e.progress);
// CRITICAL FIX: On real iOS devices, onStart can fire with progress: 1 before animation completes
// Even if progress is 1, we should NOT set isKeyboardFullyOpen to true in onStart because:
// 1. onMove events may still come with lower progress values
// 2. Only onEnd with progress === 1 should mark the keyboard as fully open
// This prevents jerky behavior where the input container jumps down and back up
const wasAlreadyOpen = keyboardTranslateY.value > 0;
// Always treat as transitioning if we might receive more events (even with progress: 1)
// Only exception: if height is 0 (keyboard closing)
const shouldTreatAsTransitioning = e.height > 0 && (e.progress < 1 || wasAlreadyOpen);
keyboardTranslateY.value = adjustedHeight;
// Update keyboard state flags
// NEVER mark as fully open in onStart - always wait for onEnd to confirm
isKeyboardFullyClosed.value = e.height === 0;
isKeyboardFullyOpen.value = false; // Always false in onStart - onEnd will set it correctly
isKeyboardInTransition.value = e.height > 0 && shouldTreatAsTransitioning;
// When keyboard closes (height: 0), preserve isKeyboardClosing flag if it was true
// This prevents onMove events from processing stale closing animation values
// Only reset if keyboard was actually open (not already closing)
if (e.height === 0) {
// If keyboard was closing, keep the flag true so onMove knows to ignore stale events
// If keyboard was opening, reset the flag
if (!isKeyboardClosing.value) {
isKeyboardClosing.value = false;
}
// Always reset interactive gesture flag when keyboard closes
isInteractiveGesture.value = false;
}
// Update scroll view insets and offsets
// bottomInset: Adds bottom padding to scroll content
bottomInset.value = adjustedHeight;
// CRITICAL FIX: Don't update scrollOffset in onStart if progress === 1 and keyboard wasn't already open
// On real iOS devices, onStart can fire with progress: 1 BEFORE the animation starts
// This causes scrollOffset to jump immediately to the final value, triggering a scroll
// Then onMove events come with intermediate values, causing jerky "down then up" behavior
// Solution: Only update scrollOffset in onStart if progress < 1 OR keyboard was already open
// Let onMove handle the smooth animation for opening keyboards
// Note: wasAlreadyOpen is checked BEFORE keyboardTranslateY.value is updated, so use that
if (e.progress < 1 || wasAlreadyOpen || e.height === 0) {
// scrollOffset: Ensures the scroll view scrolls with the keyboard animation
scrollOffset.value = adjustedHeight;
} else {
// Don't update scrollOffset yet - wait for onMove to animate smoothly
// This prevents the initial jump when keyboard opens
}
},
/**
* onInteractive: Called continuously while user drags keyboard interactively
* This provides smooth real-time updates as the user swipes
*
* @param e - Event object with keyboard information
*/
onInteractive: (e) => {
'worklet';
// Ignore keyboard events when showing custom view (emoji picker)
if (isInputAccessoryViewMode.value) {
return;
}
// Ignore keyboard events during transition from custom view to keyboard
if (isTransitioningFromCustomView.value) {
return;
}
// On Android, use native keyboard behavior (no custom animations)
if (!isEnabled.value || !enableAnimation) {
return;
}
if (parseInt(e.height.toString()) === postInputContainerHeight) {
return;
}
// Ignore stale interactive events during screen navigation
// When navigating from channel (keyboard open) to thread, the channel's keyboard dismissal
// can trigger stale onInteractive events that arrive at the newly mounted thread screen.
// If keyboard is fully closed (keyboardTranslateY === 0), any onInteractive event is stale and should be ignored.
if (keyboardTranslateY.value === 0) {
return;
}
const previousKeyboardTranslateY = keyboardTranslateY.value;
if (keyboardTranslateY.value > 0) {
isInteractiveGesture.value = true;
}
// Calculate adjusted height first to compare properly
const adjustedHeight = e.height - (tabBarAdjustment * e.progress);
// Track if keyboard is closing (adjusted height decreasing) or opening (adjusted height increasing)
// Compare adjusted heights, not raw event height vs adjusted height
// This detects direction changes mid-gesture for smooth animations
if (adjustedHeight < previousKeyboardTranslateY) {
// Keyboard is closing - height is decreasing
isKeyboardClosing.value = true;
} else if (adjustedHeight > previousKeyboardTranslateY) {
// User changed direction - swiped back up
isKeyboardClosing.value = false;
}
// If adjustedHeight === previousKeyboardTranslateY, keep current isKeyboardClosing state
keyboardTranslateY.value = adjustedHeight;
scrollOffset.value = adjustedHeight;
bottomInset.value = adjustedHeight;
// Update keyboard state flags
isKeyboardFullyClosed.value = e.height === 0;
isKeyboardFullyOpen.value = e.height > 0 && e.progress === 1;
isKeyboardInTransition.value = e.height > 0 && e.progress < 1;
},
/**
* onMove: Called continuously as keyboard animates (programmatic or gesture)
* Similar to onInteractive but for all keyboard movements
*
* @param e - Event object with keyboard information
*/
onMove: (e) => {
'worklet';
// Ignore keyboard events when showing custom view (emoji picker)
if (isInputAccessoryViewMode.value) {
return;
}
// During transition from custom view, don't update keyboardHeight from onMove events
// These events can be stale/out-of-order. Wait for onEnd to get the final correct height.
if (isTransitioningFromCustomView.value) {
return;
}
// On Android, use native keyboard behavior (no custom animations)
if (!isEnabled.value || !enableAnimation) {
return;
}
// CRITICAL FIX: Ignore stale onMove events after keyboard closes
// When keyboard closes, onStart fires with height: 0, but onMove may still fire with stale values
// If keyboardHeight is 0 (closed), ignore any onMove events that try to set positive heights
if (keyboardHeight.value === 0 && e.height > 0) {
return;
}
// If keyboard is closing (detected in onInteractive), ignore onMove events
// This prevents stale closing animation values from causing jumps
if (isKeyboardClosing.value) {
return;
}
// Ignore adjustment event from KeyboardGestureArea (fires after keyboard fully opens)
// After keyboard reaches full height, KeyboardGestureArea sends offset-adjusted event
// We use both offset prop AND manual animation, so this would cause flicker
// Example: keyboard opens at 346px → then adjustment event at 229px (346 - 117 offset)
if (parseInt(e.height.toString()) === keyboardHeight.value - postInputContainerHeight) {
return;
}
// CRITICAL FIX: Ignore invalid onMove events with progress > 1
// On real iOS devices, sometimes onMove fires with progress > 1 (e.g., 1.266)
// These are invalid events that can cause the input container to jump
// Progress should always be between 0 and 1 for valid keyboard animation events
if (e.progress > 1) {
return;
}
const absHeight = Math.abs(e.height) - (tabBarAdjustment * e.progress); // Use Math.abs because programmatic dismiss (KeyboardController.dismiss()) reports negative heights
// CRITICAL FIX: On real iOS devices, onStart can fire with progress: 1 before animation completes
// Then onMove events come with lower heights/progress, causing jerky behavior
// If we've already reached the final keyboard height (isKeyboardFullyOpen or keyboardHeight matches final),
// ignore onMove events that would reduce keyboardTranslateY below the final value
// BUT: Always allow scrollOffset and bottomInset to update during animation for smooth scrolling
const finalHeight = keyboardHeight.value;
const finalAdjustedHeight = finalHeight > 0 ? finalHeight - tabBarAdjustment : 0;
const hasReachedFinalState = finalHeight > 0 && keyboardTranslateY.value >= finalAdjustedHeight * 0.95; // 95% threshold to account for rounding
if (hasReachedFinalState && absHeight < keyboardTranslateY.value && e.progress < 1) {
// Still update scrollOffset and bottomInset even if we ignore keyboardTranslateY
// This ensures the list scrolls smoothly with the keyboard animation
scrollOffset.value = absHeight;
bottomInset.value = absHeight;
return;
}
// Ignore stale/incorrect events ONLY during/after interactive gestures
// After user releases finger mid-swipe up, onMove sometimes gets wrong height values
// Example: keyboard at 346px, user releases, onMove reports 80px (stale from earlier)
// This check only applies during interactive gestures, not during normal keyboard opening
if (isInteractiveGesture.value && absHeight < keyboardTranslateY.value && e.progress < 1) {
return;
}
keyboardTranslateY.value = absHeight;
scrollOffset.value = absHeight;
bottomInset.value = absHeight;
// Update keyboard state flags
isKeyboardFullyClosed.value = absHeight === 0;
isKeyboardFullyOpen.value = absHeight > 0 && e.progress === 1;
isKeyboardInTransition.value = absHeight > 0 && e.progress < 1;
},
onEnd: (e) => {
'worklet';
// Skip processing if screen is not enabled/visible or if animations are disabled
if (!isEnabled.value) {
return;
}
// Ignore keyboard events when showing custom view (emoji picker)
if (isInputAccessoryViewMode.value) {
isKeyboardClosing.value = false;
isTransitioningFromCustomView.value = false;
return;
}
// On Android, use native keyboard behavior (no custom animations)
if (!enableAnimation) {
isKeyboardClosing.value = false;
isTransitioningFromCustomView.value = false;
return;
}
// Ignore adjustment event from KeyboardGestureArea (can fire in onEnd too)
// After keyboard reaches full height, KeyboardGestureArea sends offset-adjusted event
// Example: keyboard opens at 346px → then adjustment event at 255px (346 - 91 offset)
if (parseInt(e.height.toString()) === keyboardHeight.value - postInputContainerHeight) {
return;
}
// Store if we were transitioning from custom view before clearing the flag
const wasTransitioningFromCustomView = isTransitioningFromCustomView.value;
// Reset state flags
isKeyboardClosing.value = false;
isTransitioningFromCustomView.value = false;
isInteractiveGesture.value = false;
// Use e.progress (from event) not progress.value (shared value might be stale)
if (e.progress === 1) {
// CRITICAL FIX: Update keyboardHeight FIRST from onEnd event (most reliable source)
// During transition, onMove events can be stale/out-of-order, so onEnd's height is authoritative
// Store previous value for stale check before updating
const previousKeyboardHeight = keyboardHeight.value;
keyboardHeight.value = e.height;
// Use same calculation as onInteractive/onMove for consistency
const adjustedHeight = e.height - (tabBarAdjustment * e.progress);
// Ignore stale/out-of-order events
// If keyboard is supposed to be closed (previousKeyboardHeight = 0) but we get an open event,
// it's a stale event from before the close - ignore it
if (previousKeyboardHeight === 0 && e.height > 0) {
return;
}
// If transitioning from custom view, always update keyboardTranslateY to match keyboard
// This ensures correct positioning when emoji picker height != keyboard height
if (wasTransitioningFromCustomView) {
keyboardTranslateY.value = adjustedHeight;
} else if (Math.abs(keyboardTranslateY.value - adjustedHeight) > 1) {
// For normal keyboard opening, only adjust if significantly different
keyboardTranslateY.value = adjustedHeight;
}
// Update bottomInset and scrollOffset to match final keyboard height
// This ensures messages don't get hidden behind keyboard
bottomInset.value = adjustedHeight;
scrollOffset.value = adjustedHeight;
isKeyboardFullyOpen.value = true;
isKeyboardFullyClosed.value = false;
isKeyboardInTransition.value = false;
}
// Use e.progress (from event) not progress.value (shared value might be stale)
if (e.progress === 0) {
// Only set to 0 if not already close to 0
if (Math.abs(keyboardTranslateY.value) > 0.5) {
keyboardTranslateY.value = 0;
}
isKeyboardFullyOpen.value = false;
isKeyboardFullyClosed.value = true;
isKeyboardInTransition.value = false;
scrollOffset.value = 0;
// CRITICAL FIX: Reset scrollPosition when keyboard closes
// scrollPosition = contentOffsetY + bottomInset
// Before bottomInset becomes 0, calculate actual content offset: scrollPosition - bottomInset
// This preserves the user's scroll position even if iOS resets the list to 0
const currentBottomInset = bottomInset.value;
if (scrollPosition.value > currentBottomInset) {
scrollPosition.value = scrollPosition.value - currentBottomInset;
}
bottomInset.value = 0;
// Reset closing flag when keyboard fully closes
isKeyboardClosing.value = false;
isInteractiveGesture.value = false;
}
},
});
// ------------------------------------------------------------------
// SCROLL HANDLER
// ------------------------------------------------------------------
/**
* Handle scroll events on the UI thread for smooth performance
* Tracks scroll position to calculate proper keyboard offset
*/
const onScroll = useAnimatedScrollHandler({
onScroll: (e) => {
const newScrollPosition = e.contentOffset.y + bottomInset.value;
// Preserve scrollPosition when keyboard is closed and list resets to 0
// This prevents iOS's list reset from overwriting the preserved scroll position
if (bottomInset.value > 0 || e.contentOffset.y > 0 || scrollPosition.value === 0) {
scrollPosition.value = newScrollPosition;
}
},
});
return {
keyboardTranslateY,
onScroll,
bottomInset,
scrollOffset,
keyboardHeight,
scrollPosition,
isKeyboardFullyOpen,
isKeyboardFullyClosed,
isKeyboardInTransition,
isInputAccessoryViewMode,
isTransitioningFromCustomView,
};
};