Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

The Great Unjanking of the Sheets (#9973)

authored by

Samuel Newman and committed by
GitHub
aa897f55 18d7e775

+317 -195
+2 -2
modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt
··· 25 25 view.dismiss() 26 26 } 27 27 28 - AsyncFunction("updateLayout") { view: BottomSheetView -> 29 - view.updateLayout() 28 + Prop("fullHeight") { view: BottomSheetView, prop: Boolean -> 29 + view.fullHeight = prop 30 30 } 31 31 32 32 Prop("disableDrag") { view: BottomSheetView, prop: Boolean ->
+146 -54
modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt
··· 8 8 import android.view.Window 9 9 import android.view.accessibility.AccessibilityEvent 10 10 import android.widget.FrameLayout 11 - import androidx.core.view.ViewCompat 12 - import androidx.core.view.WindowInsetsCompat 13 11 import androidx.core.view.WindowInsetsControllerCompat 14 - import androidx.core.view.allViews 15 12 import com.facebook.react.bridge.LifecycleEventListener 16 13 import com.facebook.react.bridge.ReactContext 17 14 import com.facebook.react.bridge.UiThreadUtil ··· 34 31 35 32 private lateinit var dialogRootViewGroup: DialogRootViewGroup 36 33 private var eventDispatcher: EventDispatcher? = null 37 - private var isKeyboardVisible: Boolean = false 38 34 39 - private val screenHeight = 40 - context.resources.displayMetrics.heightPixels 41 - .toFloat() 35 + // Native content height observation (eliminates JS bridge round-trip) 36 + private var contentLayoutListener: View.OnLayoutChangeListener? = null 37 + private var observedChildren: List<View> = emptyList() 38 + private var lastObservedContentHeight: Float = 0f 39 + private var pendingLayoutUpdate: Boolean = false 40 + 41 + private val screenHeight: Float = 42 + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) { 43 + context.resources.displayMetrics.heightPixels.toFloat() 44 + } else { 45 + val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager 46 + wm.currentWindowMetrics.bounds.height().toFloat() 47 + } 42 48 43 49 private fun getNavigationBarHeight(): Int { 44 50 val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") ··· 64 70 set(value) { 65 71 field = value 66 72 this.dialog?.setCancelable(!value) 73 + // Full-height sheets have no half-expanded snap point, so any drag 74 + // would dismiss. Disable dragging when dismiss is prevented. 75 + if (fullHeight) { 76 + this.setDraggable(!value && !disableDrag) 77 + } 67 78 } 68 79 80 + var fullHeight = false 81 + 69 82 var preventExpansion = false 70 83 71 84 var minHeight = 0f ··· 129 142 } 130 143 131 144 private fun destroy() { 145 + this.stopObservingContentHeight() 132 146 this.isClosing = false 133 147 this.isOpen = false 134 148 this.dialog = null ··· 193 207 val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet) 194 208 bottomSheet?.let { 195 209 it.setBackgroundColor(0) 210 + it.elevation = 0f 196 211 197 212 val behavior = BottomSheetBehavior.from(it) 198 213 behavior.state = BottomSheetBehavior.STATE_HIDDEN 199 - behavior.isFitToContents = true 200 - behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight) 201 214 behavior.skipCollapsed = true 202 215 behavior.isDraggable = true 203 216 behavior.isHideable = true 204 - 205 - if (preventExpansion) { 206 - behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt() 207 - } else { 208 - behavior.maxHeight = (screenHeight - getStatusBarHeight()).toInt() 209 - } 210 - 211 - val targetHeight = this.getTargetHeight() 212 - val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight() 213 - val shouldBeExpanded = targetHeight >= availableHeight 214 - 215 - if (shouldBeExpanded) { 217 + if (fullHeight) { 218 + behavior.isFitToContents = false 219 + behavior.expandedOffset = getStatusBarHeight() 216 220 behavior.state = BottomSheetBehavior.STATE_EXPANDED 217 221 this.selectedSnapPoint = 2 218 - } else { 222 + } else if (preventExpansion) { 223 + behavior.isFitToContents = true 224 + behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight) 225 + behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt() 219 226 behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED 220 227 this.selectedSnapPoint = 1 228 + } else { 229 + behavior.isFitToContents = false 230 + behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight) 231 + behavior.expandedOffset = getStatusBarHeight() 232 + 233 + val targetHeight = this.getTargetHeight() 234 + val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight() 235 + val shouldBeExpanded = targetHeight >= availableHeight 236 + 237 + if (shouldBeExpanded) { 238 + behavior.state = BottomSheetBehavior.STATE_EXPANDED 239 + this.selectedSnapPoint = 2 240 + } else { 241 + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED 242 + this.selectedSnapPoint = 1 243 + } 221 244 } 222 245 223 246 behavior.addBottomSheetCallback( ··· 226 249 bottomSheet: View, 227 250 newState: Int, 228 251 ) { 252 + if (newState == BottomSheetBehavior.STATE_EXPANDED && preventExpansion) { 253 + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED 254 + return 255 + } 229 256 when (newState) { 230 257 BottomSheetBehavior.STATE_EXPANDED -> selectedSnapPoint = 2 231 258 BottomSheetBehavior.STATE_COLLAPSED -> selectedSnapPoint = 1 232 259 BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1 233 260 BottomSheetBehavior.STATE_HIDDEN -> selectedSnapPoint = 0 234 261 } 262 + // Apply deferred layout update after gesture completes 263 + if (newState != BottomSheetBehavior.STATE_DRAGGING && 264 + newState != BottomSheetBehavior.STATE_SETTLING && 265 + pendingLayoutUpdate) { 266 + pendingLayoutUpdate = false 267 + updateLayout() 268 + } 235 269 } 236 270 237 271 override fun onSlide( ··· 245 279 this.isOpening = true 246 280 dialog.show() 247 281 this.dialog = dialog 248 - 249 - ViewCompat.setOnApplyWindowInsetsListener(dialogRootViewGroup) { view, insets -> 250 - val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) 251 - val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet) 252 - val behavior = bottomSheet?.let { BottomSheetBehavior.from(it) } 282 + if (!fullHeight) { 283 + this.startObservingContentHeight() 284 + } 253 285 254 - val wasKeyboardVisible = isKeyboardVisible 255 - isKeyboardVisible = imeVisible 256 - 257 - if (imeVisible && behavior?.state == BottomSheetBehavior.STATE_HALF_EXPANDED) { 258 - behavior.state = BottomSheetBehavior.STATE_EXPANDED 259 - } else if (!imeVisible && wasKeyboardVisible) { 260 - updateLayout() 261 - } 262 - insets 263 - } 264 286 } 265 287 266 288 fun updateLayout() { 289 + if (fullHeight) return 267 290 val dialog = this.dialog ?: return 268 291 val contentHeight = this.getContentHeight() 269 292 ··· 274 297 275 298 val oldRatio = behavior.halfExpandedRatio 276 299 val newRatio = getHalfExpandedRatio(contentHeight) 300 + 301 + val targetHeight = this.getTargetHeight() 302 + val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight() 303 + val shouldBeExpanded = targetHeight >= availableHeight 304 + 305 + // Don't update during user gestures — defer until the gesture completes. 306 + if (currentState == BottomSheetBehavior.STATE_DRAGGING) { 307 + pendingLayoutUpdate = true 308 + return 309 + } 310 + 277 311 behavior.halfExpandedRatio = newRatio 278 312 279 313 if (preventExpansion) { 280 314 behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt() 315 + it.requestLayout() 281 316 } 282 317 283 - val targetHeight = this.getTargetHeight() 284 - val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight() 285 - val shouldBeExpanded = targetHeight >= availableHeight 286 - 287 - if (isKeyboardVisible) { 288 - if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) { 289 - behavior.state = BottomSheetBehavior.STATE_EXPANDED 318 + // During settling (programmatic animation from our own state change), 319 + // redirect the animation to the new position if the ratio changed. 320 + if (currentState == BottomSheetBehavior.STATE_SETTLING) { 321 + if (oldRatio != newRatio) { 322 + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED 290 323 } 291 - } else if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) { 324 + return 325 + } 326 + 327 + if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) { 292 328 behavior.state = BottomSheetBehavior.STATE_EXPANDED 293 329 } else if (!shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) { 294 330 behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED ··· 299 335 } 300 336 301 337 fun dismiss() { 302 - this.dialog?.dismiss() 338 + val dialog = this.dialog ?: return 339 + // Mark as closing so the content observer doesn't fight the dismiss 340 + // animation by calling updateLayout() mid-hide. 341 + this.isClosing = true 342 + // Temporarily make cancelable so cancel() works — cancel() gives the 343 + // slide-out animation, while dismiss() does a plain fade. 344 + dialog.setCancelable(true) 345 + dialog.cancel() 346 + } 347 + 348 + // Observe each direct child of innerView via OnLayoutChangeListener so that 349 + // height updates are detected purely on the native side. We use OnLayoutChangeListener 350 + // (not OnGlobalLayoutListener) because React Native calls view.layout() directly 351 + // via Yoga, bypassing requestLayout()/performTraversals(). OnLayoutChangeListener 352 + // fires from setFrame() which IS called by layout(), so it catches RN updates. 353 + private fun startObservingContentHeight() { 354 + stopObservingContentHeight() 355 + 356 + val innerViewGroup = this.innerView as? ViewGroup ?: return 357 + 358 + val listener = View.OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom -> 359 + val newHeight = bottom - top 360 + val oldHeight = oldBottom - oldTop 361 + if (newHeight != oldHeight) { 362 + val contentHeight = getContentHeight() 363 + if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) { 364 + lastObservedContentHeight = contentHeight 365 + updateLayout() 366 + } 367 + } 368 + } 369 + 370 + val children = mutableListOf<View>() 371 + for (i in 0 until innerViewGroup.childCount) { 372 + val child = innerViewGroup.getChildAt(i) 373 + child.addOnLayoutChangeListener(listener) 374 + children.add(child) 375 + } 376 + 377 + this.contentLayoutListener = listener 378 + this.observedChildren = children 379 + 380 + // Pick up current height if content is already laid out 381 + val contentHeight = getContentHeight() 382 + if (contentHeight > 0 && contentHeight != lastObservedContentHeight) { 383 + lastObservedContentHeight = contentHeight 384 + updateLayout() 385 + } 386 + } 387 + 388 + private fun stopObservingContentHeight() { 389 + contentLayoutListener?.let { listener -> 390 + observedChildren.forEach { it.removeOnLayoutChangeListener(listener) } 391 + } 392 + contentLayoutListener = null 393 + observedChildren = emptyList() 394 + lastObservedContentHeight = 0f 303 395 } 304 396 305 397 // Util 306 398 307 399 private fun getContentHeight(): Float { 308 - val innerView = this.innerView ?: return 0f 309 - var index = 0 310 - innerView.allViews.forEach { 311 - if (index == 1) { 312 - return it.height.toFloat() 313 - } 314 - index++ 400 + val innerView = this.innerView as? ViewGroup ?: return 0f 401 + // Use the tallest direct child's height. The handle is absolutely positioned 402 + // (overlaps the content), so summing would double-count its height as padding. 403 + var maxChildHeight = 0f 404 + for (i in 0 until innerView.childCount) { 405 + val h = innerView.getChildAt(i).height.toFloat() 406 + if (h > maxChildHeight) maxChildHeight = h 315 407 } 316 - return 0f 408 + return maxChildHeight 317 409 } 318 410 319 411 private fun getTargetHeight(): Float {
+5 -2
modules/bottom-sheet/android/src/main/res/values/styles.xml
··· 1 1 <?xml version="1.0" encoding="utf-8"?> 2 2 <resources> 3 - <style name="EdgeToEdgeBottomSheetDialogTheme" parent="Theme.Material3.DayNight.BottomSheetDialog"> 4 - <!-- Enable edge-to-edge --> 3 + <style name="EdgeToEdgeBottomSheetDialogTheme" parent="ThemeOverlay.Material3.DayNight.BottomSheetDialog"> 4 + <!-- Enable edge-to-edge, matching react-native-edge-to-edge's setup --> 5 5 <item name="android:navigationBarColor">@android:color/transparent</item> 6 6 <item name="android:statusBarColor">@android:color/transparent</item> 7 7 <item name="android:windowIsFloating">false</item> 8 + <item name="android:windowDrawsSystemBarBackgrounds">true</item> 9 + <item name="android:fitsSystemWindows">false</item> 8 10 <item name="enableEdgeToEdge">true</item> 9 11 10 12 <!-- Configure bottom sheet to respect system window insets --> ··· 16 18 <item name="paddingLeftSystemWindowInsets">true</item> 17 19 <item name="paddingRightSystemWindowInsets">true</item> 18 20 <item name="paddingTopSystemWindowInsets">false</item> 21 + <item name="backgroundTint">@android:color/transparent</item> 19 22 </style> 20 23 </resources>
+2 -2
modules/bottom-sheet/ios/BottomSheetModule.swift
··· 19 19 view.dismiss() 20 20 } 21 21 22 - AsyncFunction("updateLayout") { (view: SheetView) in 23 - view.updateLayout() 22 + Prop("fullHeight") { (view: SheetView, prop: Bool) in 23 + view.fullHeight = prop 24 24 } 25 25 26 26 Prop("cornerRadius") { (view: SheetView, prop: Float) in
+32 -9
modules/bottom-sheet/ios/SheetView.swift
··· 8 8 private var innerView: UIView? 9 9 private var touchHandler: RCTTouchHandler? 10 10 11 + // Native content height observation (eliminates JS bridge round-trip) 12 + private var contentHeightObservation: NSKeyValueObservation? 13 + 11 14 // Events 12 15 private let onAttemptDismiss = EventDispatcher() 13 16 private let onSnapPointChange = EventDispatcher() ··· 23 26 } 24 27 25 28 // React view props 29 + var fullHeight = false 26 30 var preventDismiss = false 27 31 var preventExpansion = false 28 32 var cornerRadius: CGFloat? ··· 68 72 } 69 73 } 70 74 } 71 - private var prevLayoutDetentIdentifier: UISheetPresentationController.Detent.Identifier? 72 75 73 76 // MARK: - Lifecycle 74 77 ··· 106 109 } 107 110 108 111 private func destroy() { 112 + self.contentHeightObservation?.invalidate() 113 + self.contentHeightObservation = nil 109 114 self.isClosing = false 110 115 self.isOpen = false 111 116 self.sheetVc = nil ··· 128 133 } 129 134 130 135 let sheetVc = SheetViewController() 131 - sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion) 136 + sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion, fullHeight: self.fullHeight) 132 137 if let sheet = sheetVc.sheetPresentationController { 133 138 sheet.delegate = self 134 139 sheet.preferredCornerRadius = self.cornerRadius ··· 147 152 148 153 self.sheetVc = sheetVc 149 154 self.isOpening = true 155 + if !self.fullHeight { 156 + self.startObservingContentHeight() 157 + } 150 158 151 159 rvc.present(sheetVc, animated: true) { [weak self] in 152 160 self?.isOpening = false ··· 154 162 } 155 163 } 156 164 157 - func updateLayout() { 158 - // Allow updates either when identifiers match OR when prevLayoutDetentIdentifier is nil (first real content update) 159 - if self.prevLayoutDetentIdentifier == self.selectedDetentIdentifier || self.prevLayoutDetentIdentifier == nil, 160 - let contentHeight = self.innerView?.subviews.first?.frame.size.height { 161 - self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight), 162 - preventExpansion: self.preventExpansion) 165 + // Observe the content view's bounds via KVO so that height changes are detected 166 + // purely on the native side, without a JS bridge round-trip through onLayout. 167 + // Calls updateDetents directly with the observed height rather than going through 168 + // updateLayout(), which has a prevLayoutDetentIdentifier guard that can block 169 + // legitimate content-driven updates when detent identifiers drift during animations. 170 + private func startObservingContentHeight() { 171 + self.contentHeightObservation?.invalidate() 172 + 173 + guard let contentView = self.innerView?.subviews.first else { return } 174 + 175 + self.contentHeightObservation = contentView.observe( 176 + \.bounds, 177 + options: [.old, .new] 178 + ) { [weak self] _, change in 179 + guard let self = self, 180 + (self.isOpen || self.isOpening) && !self.isClosing, 181 + let oldBounds = change.oldValue, 182 + let newBounds = change.newValue, 183 + oldBounds.height != newBounds.height, 184 + newBounds.height > 0 else { return } 185 + let clampedHeight = self.clampHeight(newBounds.height) 186 + self.sheetVc?.updateDetents(contentHeight: clampedHeight, preventExpansion: self.preventExpansion) 163 187 self.selectedDetentIdentifier = self.sheetVc?.getCurrentDetentIdentifier() 164 188 } 165 - self.prevLayoutDetentIdentifier = self.selectedDetentIdentifier 166 189 } 167 190 168 191 func dismiss() {
+7 -1
modules/bottom-sheet/ios/SheetViewController.swift
··· 20 20 } 21 21 } 22 22 23 - func setDetents(contentHeight: CGFloat, preventExpansion: Bool) { 23 + func setDetents(contentHeight: CGFloat, preventExpansion: Bool, fullHeight: Bool = false) { 24 24 guard let sheet = self.sheetPresentationController, 25 25 let screenHeight = Util.getScreenHeight() 26 26 else { 27 + return 28 + } 29 + 30 + if fullHeight { 31 + sheet.detents = [.large()] 32 + sheet.selectedDetentIdentifier = .large 27 33 return 28 34 } 29 35
+1
modules/bottom-sheet/src/BottomSheet.types.ts
··· 26 26 disableDrag?: boolean 27 27 sourceViewTag?: number 28 28 29 + fullHeight?: boolean 29 30 minHeight?: number 30 31 maxHeight?: number 31 32
+19 -24
modules/bottom-sheet/src/BottomSheetNativeComponent.tsx
··· 12 12 import {useSafeAreaInsets} from 'react-native-safe-area-context' 13 13 import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' 14 14 15 - import {IS_IOS} from '#/env' 16 15 import { 17 16 type BottomSheetState, 18 17 type BottomSheetViewProps, ··· 35 34 Platform.OS === 'ios' && 36 35 // semvar - can be 3 segments, so can't use Number(Platform.Version) 37 36 Number(Platform.Version.split('.').at(0)) < 16 37 + // older android versions (15 and below) aren't naturally edge-to-edge 38 + // and behave a little differently 39 + const IS_NON_E2E_ANDROID = 40 + Platform.OS === 'android' && Number(Platform.Version) < 35 38 41 39 42 export class BottomSheetNativeComponent extends React.Component< 40 43 BottomSheetViewProps, ··· 71 74 this.props.onStateChange?.(event) 72 75 } 73 76 74 - private updateLayout = () => { 75 - this.ref.current?.updateLayout() 76 - } 77 - 78 77 static dismissAll = async () => { 79 78 await NativeModule.dismissAll() 80 79 } ··· 113 112 nativeViewRef={this.ref} 114 113 onStateChange={this.onStateChange} 115 114 extraStyles={extraStyles} 116 - onLayout={e => { 117 - if (IS_IOS15) { 118 - const {height} = e.nativeEvent.layout 119 - this.setState({viewHeight: height}) 120 - } 121 - if (Platform.OS === 'android') { 122 - // TEMP HACKFIX: I had to timebox this, but this is Bad. 123 - // On Android, if you run updateLayout() immediately, 124 - // it will take ages to actually run on the native side. 125 - // However, adding literally any delay will fix this, including 126 - // a console.log() - just sending the log to the CLI is enough. 127 - // TODO: Get to the bottom of this and fix it properly! -sfn 128 - setTimeout(() => this.updateLayout()) 129 - } else { 130 - this.updateLayout() 131 - } 132 - }} 115 + onLayout={ 116 + IS_IOS15 117 + ? e => { 118 + const {height} = e.nativeEvent.layout 119 + this.setState({viewHeight: height}) 120 + } 121 + : undefined 122 + } 133 123 /> 134 124 </Portal> 135 125 ) ··· 150 140 event: NativeSyntheticEvent<{state: BottomSheetState}>, 151 141 ) => void 152 142 nativeViewRef: React.RefObject<View> 153 - onLayout: (event: LayoutChangeEvent) => void 143 + onLayout?: (event: LayoutChangeEvent) => void 154 144 }) { 155 145 const insets = useSafeAreaInsets() 156 146 const cornerRadius = rest.cornerRadius ?? 0 157 147 const {height: screenHeight} = useWindowDimensions() 158 148 159 - const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight 149 + // sigh... on older Android versions, screenHeight does not include safe area insets 150 + // on newer Androids + iOS, it does. we need to find the inner bit + the bottom inset 151 + // for the sheet content 152 + const sheetHeight = IS_NON_E2E_ANDROID 153 + ? screenHeight + insets.bottom 154 + : screenHeight - insets.top 160 155 161 156 return ( 162 157 <NativeView
+50 -34
src/components/Dialog/index.tsx
··· 1 - import React, {useImperativeHandle} from 'react' 1 + import { 2 + forwardRef, 3 + useCallback, 4 + useImperativeHandle, 5 + useMemo, 6 + useRef, 7 + useState, 8 + } from 'react' 2 9 import { 10 + Keyboard, 11 + type KeyboardEventListener, 3 12 type LayoutChangeEvent, 4 13 type NativeScrollEvent, 5 14 type NativeSyntheticEvent, ··· 34 43 type DialogOuterProps, 35 44 } from '#/components/Dialog/types' 36 45 import {createInput} from '#/components/forms/TextField' 46 + import {useOnKeyboard} from '#/components/hooks/useOnKeyboard' 37 47 import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS} from '#/env' 38 48 import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet' 39 49 import { ··· 58 68 }: React.PropsWithChildren<DialogOuterProps>) { 59 69 const themeName = useThemeName() 60 70 const t = useTheme(themeName) 61 - const ref = React.useRef<BottomSheetNativeComponent>(null) 62 - const closeCallbacks = React.useRef<(() => void)[]>([]) 71 + const ref = useRef<BottomSheetNativeComponent>(null) 72 + const closeCallbacks = useRef<(() => void)[]>([]) 63 73 const {setDialogIsOpen, setFullyExpandedCount} = 64 74 useDialogStateControlContext() 65 75 66 - const prevSnapPoint = React.useRef<BottomSheetSnapPoint>( 76 + const prevSnapPoint = useRef<BottomSheetSnapPoint>( 67 77 BottomSheetSnapPoint.Hidden, 68 78 ) 69 79 70 - const [disableDrag, setDisableDrag] = React.useState(false) 71 - const [snapPoint, setSnapPoint] = React.useState<BottomSheetSnapPoint>( 80 + const [disableDrag, setDisableDrag] = useState(false) 81 + const [snapPoint, setSnapPoint] = useState<BottomSheetSnapPoint>( 72 82 BottomSheetSnapPoint.Partial, 73 83 ) 74 84 75 - const callQueuedCallbacks = React.useCallback(() => { 85 + const callQueuedCallbacks = useCallback(() => { 76 86 for (const cb of closeCallbacks.current) { 77 87 try { 78 88 cb() ··· 84 94 closeCallbacks.current = [] 85 95 }, []) 86 96 87 - const open = React.useCallback<DialogControlProps['open']>(() => { 97 + const open = useCallback<DialogControlProps['open']>(() => { 88 98 // Run any leftover callbacks that might have been queued up before calling `.open()` 89 99 callQueuedCallbacks() 90 100 setDialogIsOpen(control.id, true) ··· 92 102 }, [setDialogIsOpen, control.id, callQueuedCallbacks]) 93 103 94 104 // This is the function that we call when we want to dismiss the dialog. 95 - const close = React.useCallback<DialogControlProps['close']>(cb => { 105 + const close = useCallback<DialogControlProps['close']>(cb => { 96 106 if (typeof cb === 'function') { 97 107 closeCallbacks.current.push(cb) 98 108 } ··· 101 111 102 112 // This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to 103 113 // happen before we run this. It is passed to the `BottomSheet` component. 104 - const onCloseAnimationComplete = React.useCallback(() => { 114 + const onCloseAnimationComplete = useCallback(() => { 105 115 // This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this 106 116 // tells us that we need to toggle the accessibility overlay setting 107 117 setDialogIsOpen(control.id, false) ··· 147 157 [open, close], 148 158 ) 149 159 150 - const context = React.useMemo( 160 + const context = useMemo( 151 161 () => ({ 152 162 close, 153 163 isNativeDialog: true, ··· 201 211 ) 202 212 } 203 213 204 - export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>( 214 + export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>( 205 215 function ScrollableInner( 206 216 {children, contentContainerStyle, header, ...props}, 207 217 ref, 208 218 ) { 209 219 const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() 220 + const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full 210 221 const insets = useSafeAreaInsets() 211 - const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full 222 + const [keyboardHeight, setKeyboardHeight] = useState(() => 223 + IS_ANDROID ? (Keyboard.metrics()?.height ?? 0) : 0, 224 + ) 212 225 213 - let paddingBottom = 0 214 - if (IS_IOS) { 215 - paddingBottom = tokens.space._2xl 216 - } else { 217 - paddingBottom = 218 - Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl 219 - if (isAtMaxSnapPoint) { 220 - paddingBottom += insets.top 221 - } 222 - } 226 + const keyboardEventHandler = useCallback<KeyboardEventListener>(e => { 227 + setKeyboardHeight(e.endCoordinates.height) 228 + }, []) 229 + useOnKeyboard('keyboardDidShow', keyboardEventHandler) 230 + useOnKeyboard('keyboardDidHide', keyboardEventHandler) 223 231 224 232 const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => { 225 233 if (!IS_ANDROID) { ··· 238 246 contentContainerStyle={[ 239 247 a.pt_2xl, 240 248 IS_LIQUID_GLASS ? a.px_2xl : a.px_xl, 241 - {paddingBottom}, 249 + platform({ 250 + ios: a.pb_2xl, 251 + android: { 252 + paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl, 253 + }, 254 + }), 242 255 contentContainerStyle, 243 256 ]} 244 257 ref={ref} ··· 250 263 {...props} 251 264 bounces={isAtMaxSnapPoint} 252 265 scrollEventThrottle={50} 253 - onScroll={IS_ANDROID ? onScroll : undefined} 266 + // set drag state based on scroll on android. 267 + // we want to detect if it's at the top or not, so watch 268 + // scrollEndDrag and momentumScrollEnd as well 269 + onScroll={android(onScroll)} 270 + onScrollEndDrag={android(onScroll)} 271 + onMomentumScrollEnd={android(onScroll)} 254 272 keyboardShouldPersistTaps="handled" 255 273 // TODO: figure out why this positions the header absolutely (rather than stickily) 256 274 // on Android. fine to disable for now, because we don't have any ··· 263 281 }, 264 282 ) 265 283 266 - export const InnerFlatList = React.forwardRef< 284 + export const InnerFlatList = forwardRef< 267 285 ListMethods, 268 286 ListProps<any> & { 269 287 webInnerStyle?: StyleProp<ViewStyle> ··· 293 311 } 294 312 295 313 return ( 296 - <ScrollProvider onScroll={onScroll}> 314 + <ScrollProvider 315 + onScroll={onScroll} 316 + onEndDrag={onScroll} 317 + onMomentumEnd={onScroll}> 297 318 <List 298 319 keyboardShouldPersistTaps="handled" 299 320 contentInsetAdjustmentBehavior={ ··· 327 348 onLayout?: (event: LayoutChangeEvent) => void 328 349 }) { 329 350 const t = useTheme() 330 - const {top, bottom} = useSafeAreaInsets() 351 + const {bottom} = useSafeAreaInsets() 331 352 const {height} = useReanimatedKeyboardAnimation() 332 353 333 354 const animatedStyle = useAnimatedStyle(() => { ··· 350 371 t.atoms.border_contrast_low, 351 372 a.px_lg, 352 373 a.pt_md, 353 - { 354 - paddingBottom: platform({ 355 - ios: tokens.space.md + bottom + (IS_LIQUID_GLASS ? top : 0), 356 - android: tokens.space.md + bottom + top, 357 - }), 358 - }, 374 + {paddingBottom: bottom + tokens.space.md}, 359 375 // TODO: had to admit defeat here, but we should 360 376 // try and get this to work for Android as well -sfn 361 377 ios(animatedStyle),
+3 -10
src/components/ProgressGuide/FollowDialog.tsx
··· 1 1 import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 - import { 3 - TextInput, 4 - useWindowDimensions, 5 - View, 6 - type ViewToken, 7 - } from 'react-native' 2 + import {TextInput, View, type ViewToken} from 'react-native' 8 3 import {type ModerationOpts} from '@atproto/api' 9 4 import {msg} from '@lingui/core/macro' 10 5 import {useLingui} from '@lingui/react' ··· 72 67 const {_} = useLingui() 73 68 const control = Dialog.useDialogControl() 74 69 const {gtPhone} = useBreakpoints() 75 - const {height: minHeight} = useWindowDimensions() 76 70 77 71 return ( 78 72 <> ··· 89 83 </ButtonText> 90 84 {showArrow && <ButtonIcon icon={ArrowRightIcon} />} 91 85 </Button> 92 - <Dialog.Outer control={control} nativeOptions={{minHeight}}> 86 + <Dialog.Outer control={control} nativeOptions={{fullHeight: true}}> 93 87 <Dialog.Handle /> 94 88 <DialogInner guide={guide} /> 95 89 </Dialog.Outer> ··· 105 99 }: { 106 100 control: Dialog.DialogOuterProps['control'] 107 101 }) { 108 - const {height: minHeight} = useWindowDimensions() 109 102 return ( 110 - <Dialog.Outer control={control} nativeOptions={{minHeight}}> 103 + <Dialog.Outer control={control} nativeOptions={{fullHeight: true}}> 111 104 <Dialog.Handle /> 112 105 <DialogInner /> 113 106 </Dialog.Outer>
+1 -1
src/components/Select/index.tsx
··· 151 151 }, [items, context.value, valueExtractor, setValue]) 152 152 153 153 return ( 154 - <Dialog.Outer control={control}> 154 + <Dialog.Outer control={control} nativeOptions={{fullHeight: true}}> 155 155 <ContentInner 156 156 control={control} 157 157 items={items}
+4 -1
src/components/StarterPack/Wizard/WizardEditListDialog.tsx
··· 78 78 ) 79 79 80 80 return ( 81 - <Dialog.Outer control={control} testID="newChatDialog"> 81 + <Dialog.Outer 82 + control={control} 83 + testID="newChatDialog" 84 + nativeOptions={{fullHeight: true}}> 82 85 <Dialog.Handle /> 83 86 <Dialog.InnerFlatList 84 87 ref={listRef}
+1
src/components/dialogs/GifSelect.tsx
··· 68 68 bottomInset: 0, 69 69 // use system corner radius on iOS 70 70 ...ios({cornerRadius: undefined}), 71 + fullHeight: true, 71 72 }}> 72 73 <Dialog.Handle /> 73 74 <ErrorBoundary renderError={renderErrorBoundary}>
+3 -11
src/components/dialogs/LanguageSelectDialog.tsx
··· 1 1 import {useCallback, useMemo, useState} from 'react' 2 - import {useWindowDimensions, View} from 'react-native' 3 - import {useSafeAreaInsets} from 'react-native-safe-area-context' 2 + import {View} from 'react-native' 4 3 import {msg} from '@lingui/core/macro' 5 4 import {useLingui} from '@lingui/react' 6 5 import {Trans} from '@lingui/react/macro' ··· 17 16 import * as Toggle from '#/components/forms/Toggle' 18 17 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 19 18 import {Text} from '#/components/Typography' 20 - import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' 19 + import {IS_NATIVE, IS_WEB} from '#/env' 21 20 22 21 type FlatListItem = 23 22 | { ··· 51 50 onSelectLanguages: (languages: string[]) => void 52 51 maxLanguages?: number 53 52 }) { 54 - const {height} = useWindowDimensions() 55 - const insets = useSafeAreaInsets() 56 - 57 53 const renderErrorBoundary = useCallback( 58 54 (error: any) => <DialogError details={String(error)} />, 59 55 [], 60 56 ) 61 57 62 58 return ( 63 - <Dialog.Outer 64 - control={control} 65 - nativeOptions={{ 66 - minHeight: IS_LIQUID_GLASS ? height : height - insets.top, 67 - }}> 59 + <Dialog.Outer control={control} nativeOptions={{fullHeight: true}}> 68 60 <Dialog.Handle /> 69 61 <ErrorBoundary renderError={renderErrorBoundary}> 70 62 <DialogInner
+2 -6
src/components/dialogs/ServerInput.tsx
··· 1 1 import {useCallback, useImperativeHandle, useRef, useState} from 'react' 2 - import {useWindowDimensions, View} from 'react-native' 2 + import {View} from 'react-native' 3 3 import {msg} from '@lingui/core/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 import {Trans} from '@lingui/react/macro' ··· 28 28 onSelect: (url: string) => void 29 29 }) { 30 30 const ax = useAnalytics() 31 - const {height} = useWindowDimensions() 32 31 const formRef = useRef<DialogInnerRef>(null) 33 32 34 33 // persist these options between dialog open/close ··· 53 52 <Dialog.Outer 54 53 control={control} 55 54 onClose={onClose} 56 - nativeOptions={platform({ 57 - android: {minHeight: height / 2}, 58 - ios: {preventExpansion: true}, 59 - })}> 55 + nativeOptions={{preventExpansion: true}}> 60 56 <Dialog.Handle /> 61 57 <DialogInner 62 58 formRef={formRef}
+1 -1
src/components/dialogs/StarterPackDialog.tsx
··· 75 75 }) 76 76 77 77 return ( 78 - <Dialog.Outer control={control}> 78 + <Dialog.Outer control={control} nativeOptions={{fullHeight: true}}> 79 79 <Dialog.Handle /> 80 80 <StarterPackList 81 81 onStartWizard={wrappedNavToWizard}
+2 -3
src/components/dialogs/lists/CreateOrEditListDialog.tsx
··· 1 1 import {useCallback, useEffect, useMemo, useState} from 'react' 2 - import {useWindowDimensions, View} from 'react-native' 2 + import {View} from 'react-native' 3 3 import {type AppBskyGraphDefs, RichText as RichTextAPI} from '@atproto/api' 4 4 import {msg} from '@lingui/core/macro' 5 5 import {useLingui} from '@lingui/react' ··· 53 53 const {_} = useLingui() 54 54 const cancelControl = Dialog.useDialogControl() 55 55 const [dirty, setDirty] = useState(false) 56 - const {height} = useWindowDimensions() 57 56 58 57 // 'You might lose unsaved changes' warning 59 58 useEffect(() => { ··· 82 81 control={control} 83 82 nativeOptions={{ 84 83 preventDismiss: dirty, 85 - minHeight: height, 84 + fullHeight: true, 86 85 }} 87 86 testID="createOrEditListDialog"> 88 87 <DialogInner
+4 -1
src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx
··· 39 39 ) => void | undefined 40 40 }) { 41 41 return ( 42 - <Dialog.Outer control={control} testID="listAddRemoveUsersDialog"> 42 + <Dialog.Outer 43 + control={control} 44 + testID="listAddRemoveUsersDialog" 45 + nativeOptions={{fullHeight: true}}> 43 46 <Dialog.Handle /> 44 47 <DialogInner list={list} onChange={onChange} /> 45 48 </Dialog.Outer>
+4 -1
src/components/dms/dialogs/NewChatDialog.tsx
··· 70 70 accessibilityHint="" 71 71 /> 72 72 73 - <Dialog.Outer control={control} testID="newChatDialog"> 73 + <Dialog.Outer 74 + control={control} 75 + testID="newChatDialog" 76 + nativeOptions={{fullHeight: true}}> 74 77 <Dialog.Handle /> 75 78 <SearchablePeopleList 76 79 title={_(msg`Start a new chat`)}
+4 -1
src/components/dms/dialogs/ShareViaChatDialog.tsx
··· 17 17 onSelectChat: (chatId: string) => void 18 18 }) { 19 19 return ( 20 - <Dialog.Outer control={control} testID="sendViaChatChatDialog"> 20 + <Dialog.Outer 21 + control={control} 22 + testID="sendViaChatChatDialog" 23 + nativeOptions={{fullHeight: true}}> 21 24 <Dialog.Handle /> 22 25 <SendViaChatDialogInner control={control} onSelectChat={onSelectChat} /> 23 26 </Dialog.Outer>
+13 -6
src/components/hooks/useOnKeyboard.ts
··· 1 - import React from 'react' 2 - import {Keyboard} from 'react-native' 1 + import {useEffect} from 'react' 2 + import { 3 + Keyboard, 4 + type KeyboardEventListener, 5 + type KeyboardEventName, 6 + } from 'react-native' 3 7 4 - export function useOnKeyboardDidShow(cb: () => unknown) { 5 - React.useEffect(() => { 6 - const subscription = Keyboard.addListener('keyboardDidShow', cb) 8 + export function useOnKeyboard( 9 + eventName: KeyboardEventName, 10 + cb: KeyboardEventListener, 11 + ) { 12 + useEffect(() => { 13 + const subscription = Keyboard.addListener(eventName, cb) 7 14 8 15 return () => { 9 16 subscription.remove() 10 17 } 11 - }, [cb]) 18 + }, [eventName, cb]) 12 19 }
+2 -3
src/screens/Profile/Header/EditProfileDialog.tsx
··· 1 1 import {useCallback, useEffect, useState} from 'react' 2 - import {useWindowDimensions, View} from 'react-native' 2 + import {View} from 'react-native' 3 3 import {type AppBskyActorDefs} from '@atproto/api' 4 4 import {msg} from '@lingui/core/macro' 5 5 import {useLingui} from '@lingui/react' ··· 41 41 const {_} = useLingui() 42 42 const cancelControl = Dialog.useDialogControl() 43 43 const [dirty, setDirty] = useState(false) 44 - const {height} = useWindowDimensions() 45 44 46 45 const onPressCancel = useCallback(() => { 47 46 if (dirty) { ··· 56 55 control={control} 57 56 nativeOptions={{ 58 57 preventDismiss: dirty, 59 - minHeight: height, 58 + fullHeight: true, 60 59 }} 61 60 webOptions={{ 62 61 onBackgroundPress: () => {
+2 -3
src/screens/Settings/components/AddAppPasswordDialog.tsx
··· 1 1 import {useEffect, useMemo, useState} from 'react' 2 - import {useWindowDimensions, View} from 'react-native' 2 + import {View} from 'react-native' 3 3 import Animated, { 4 4 FadeIn, 5 5 FadeOut, ··· 34 34 control: Dialog.DialogControlProps 35 35 passwords: string[] 36 36 }) { 37 - const {height} = useWindowDimensions() 38 37 return ( 39 - <Dialog.Outer control={control} nativeOptions={{minHeight: height}}> 38 + <Dialog.Outer control={control} nativeOptions={{fullHeight: true}}> 40 39 <Dialog.Handle /> 41 40 <CreateDialogInner passwords={passwords} /> 42 41 </Dialog.Outer>
+2 -4
src/screens/Settings/components/ChangeHandleDialog.tsx
··· 1 1 import {useCallback, useMemo, useState} from 'react' 2 - import {useWindowDimensions, View} from 'react-native' 2 + import {View} from 'react-native' 3 3 import Animated, { 4 4 FadeIn, 5 5 FadeOut, ··· 53 53 }: { 54 54 control: Dialog.DialogControlProps 55 55 }) { 56 - const {height} = useWindowDimensions() 57 - 58 56 return ( 59 - <Dialog.Outer control={control} nativeOptions={{minHeight: height}}> 57 + <Dialog.Outer control={control} nativeOptions={{fullHeight: true}}> 60 58 <ChangeHandleDialogInner /> 61 59 </Dialog.Outer> 62 60 )
+2 -6
src/view/com/composer/GifAltText.tsx
··· 1 1 import {useState} from 'react' 2 - import {TouchableOpacity, useWindowDimensions, View} from 'react-native' 2 + import {TouchableOpacity, View} from 'react-native' 3 3 import {msg} from '@lingui/core/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 import {Plural, Trans} from '@lingui/react/macro' ··· 23 23 import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 24 24 import {GifEmbed} from '#/components/Post/Embed/ExternalEmbed/Gif' 25 25 import {Text} from '#/components/Typography' 26 - import {IS_ANDROID} from '#/env' 27 26 import {AltTextReminder} from './photos/Gallery' 28 27 29 28 export function GifAltTextDialog({ ··· 69 68 const {_} = useLingui() 70 69 const t = useTheme() 71 70 const [altTextDraft, setAltTextDraft] = useState(altText || vendorAltText) 72 - const {height: minHeight} = useWindowDimensions() 73 71 return ( 74 72 <> 75 73 <TouchableOpacity ··· 110 108 onClose={() => { 111 109 onSubmit(altTextDraft) 112 110 }} 113 - nativeOptions={{minHeight}}> 111 + nativeOptions={{fullHeight: true}}> 114 112 <Dialog.Handle /> 115 113 <AltTextInner 116 114 vendorAltText={vendorAltText} ··· 226 224 </View> 227 225 </View> 228 226 <Dialog.Close /> 229 - {/* Maybe fix this later -h */} 230 - {IS_ANDROID ? <View style={{height: 300}} /> : null} 231 227 </Dialog.ScrollableInner> 232 228 ) 233 229 }
+1 -1
src/view/com/composer/drafts/DraftsListDialog.tsx
··· 167 167 ) 168 168 169 169 return ( 170 - <Dialog.Outer control={control}> 170 + <Dialog.Outer control={control} nativeOptions={{fullHeight: true}}> 171 171 {/* We really really need to figure out a nice, consistent API for doing a header cross-platform -sfn */} 172 172 {IS_NATIVE && header} 173 173 <Dialog.InnerFlatList
+2 -8
src/view/com/composer/photos/ImageAltTextDialog.tsx
··· 6 6 import {Plural, Trans} from '@lingui/react/macro' 7 7 8 8 import {MAX_ALT_TEXT} from '#/lib/constants' 9 - import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' 10 9 import {enforceLen} from '#/lib/strings/helpers' 11 10 import {type ComposerImage} from '#/state/gallery' 12 11 import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' ··· 17 16 import * as TextField from '#/components/forms/TextField' 18 17 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 19 18 import {Text} from '#/components/Typography' 20 - import {IS_ANDROID, IS_LIQUID_GLASS, IS_WEB} from '#/env' 19 + import {IS_LIQUID_GLASS, IS_WEB} from '#/env' 21 20 22 21 type Props = { 23 22 control: Dialog.DialogOuterProps['control'] ··· 32 31 onChange, 33 32 sourceViewTag, 34 33 }: Props): React.ReactNode => { 35 - const {height: minHeight} = useWindowDimensions() 36 34 const [altText, setAltText] = useState(image.alt) 37 35 38 36 return ( ··· 44 42 alt: enforceLen(altText, MAX_ALT_TEXT, true), 45 43 }) 46 44 }} 47 - nativeOptions={{minHeight, sourceViewTag}}> 45 + nativeOptions={{fullHeight: true, sourceViewTag}}> 48 46 <Dialog.Handle /> 49 47 <ImageAltTextInner 50 48 control={control} ··· 70 68 const {_, i18n} = useLingui() 71 69 const t = useTheme() 72 70 const {width: screenWidth} = useWindowDimensions() 73 - 74 - const [isKeyboardVisible] = useIsKeyboardVisible() 75 71 76 72 const imageStyle = useMemo<ImageStyle>(() => { 77 73 const maxWidth = IS_WEB ··· 179 175 </Button> 180 176 </AltTextCounterWrapper> 181 177 </View> 182 - {/* Maybe fix this later -h */} 183 - {IS_ANDROID && isKeyboardVisible ? <View style={{height: 300}} /> : null} 184 178 </Dialog.ScrollableInner> 185 179 ) 186 180 }