From df002b45620bf81fd78d2b1cdaf5ff1ab6aa2e68 Mon Sep 17 00:00:00 2001 From: bogoslavskiy Date: Fri, 10 Oct 2025 03:48:48 +0300 Subject: [PATCH 01/12] feat(android): implementation --- .../com/nativesheet/NativeSheetPackage.kt | 5 +- .../java/com/nativesheet/NativeSheetView.kt | 994 +++++++++++++++++- .../com/nativesheet/NativeSheetViewManager.kt | 61 +- example/src/App.tsx | 71 +- 4 files changed, 1076 insertions(+), 55 deletions(-) diff --git a/android/src/main/java/com/nativesheet/NativeSheetPackage.kt b/android/src/main/java/com/nativesheet/NativeSheetPackage.kt index d3c41a3..5165f59 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetPackage.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetPackage.kt @@ -4,13 +4,10 @@ import com.facebook.react.ReactPackage import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.uimanager.ViewManager -import java.util.ArrayList class NativeSheetViewPackage : ReactPackage { override fun createViewManagers(reactContext: ReactApplicationContext): List> { - val viewManagers: MutableList> = ArrayList() - viewManagers.add(NativeSheetViewManager()) - return viewManagers + return listOf(NativeSheetViewManager()) } override fun createNativeModules(reactContext: ReactApplicationContext): List { diff --git a/android/src/main/java/com/nativesheet/NativeSheetView.kt b/android/src/main/java/com/nativesheet/NativeSheetView.kt index 046a5d5..5505df7 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetView.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetView.kt @@ -1,15 +1,989 @@ package com.nativesheet +import android.animation.TimeInterpolator import android.content.Context +import android.graphics.Color +import android.graphics.Outline +import android.os.SystemClock import android.util.AttributeSet -import android.view.View - -class NativeSheetView : View { - constructor(context: Context?) : super(context) - constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) - constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( - context, - attrs, - defStyleAttr - ) +import android.util.Log +import android.view.* +import android.view.animation.PathInterpolator +import android.widget.FrameLayout +import android.widget.ListView +import android.widget.ScrollView +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnLayout +import androidx.core.widget.NestedScrollView +import androidx.customview.widget.ViewDragHelper +import androidx.recyclerview.widget.RecyclerView +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.Event +import com.facebook.react.uimanager.events.RCTEventEmitter +import com.facebook.react.views.view.ReactViewGroup +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import com.facebook.react.R as ReactR +import android.graphics.Path + +class NativeSheetView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private val TAG = "NativeSheetView" + + private val backdrop = View(context).apply { + setBackgroundColor(Color.BLACK) + isClickable = true + alpha = 0f + } + + private val container = FrameLayout(context).apply { + clipToOutline = true + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + val path = Path().apply { + addRoundRect( + 0f, 0f, view.width.toFloat(), view.height.toFloat(), + floatArrayOf( + cornerRadiusPx, cornerRadiusPx, + cornerRadiusPx, cornerRadiusPx, + 0f, 0f, + 0f, 0f + ), + Path.Direction.CW + ) + } + outline.setConvexPath(path) + } + } + setBackgroundColor(Color.WHITE) + } + + internal val childrenContainer = ReactViewGroup(context) + + private var isRescanning = false + private var backdropFrozen = false + private var backdropFrozenAlpha = 0f + + private var isPresented = false + private var isAnimating = false + + private var cachedHeightPx = 0 + private var backdropMaxAlpha = 0.5f + private var cornerRadiusPx = 16f * resources.displayMetrics.density + + private var keyboardBottomInsetPx = 0 + + private var headerHeight = 0 + private var contentHeight = 0 + + private lateinit var dragHelper: ViewDragHelper + private var restTop = 0 + private var dragRange = 0 + private var invDragRange = 0f + private val velocityDismissThreshold = 1200f + + private var downY = 0f + private var draggingDownGesture = false + private var allowDragNow = false + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + + private var isDraggingSheet = false + private var handingBackToChild = false + + private var headerView: View? = null + private var contentView: View? = null + + private var headerScanExhausted = false + private var contentScanExhausted = false + private var headerLayoutListenerAttached = false + + private var scrollableChild: View? = null + private var scrollContentChild: View? = null + + private var childDelegate: ChildTouchDelegate? = null + + private var contentLayoutListenerAdded = false + private var contentGlobalLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null + + private val SUBTREE_SCAN_MAX_DEPTH = 20 + + private val layoutListener = ViewTreeObserver.OnGlobalLayoutListener { + if (isPresented) { + Log.d(TAG, "GlobalLayout -> adjustToCurrentContent(animated=true)") + adjustToCurrentContent(animated = true) + } + } + + private val spring: TimeInterpolator = PathInterpolator(0.2f, 0f, 0f, 1f) + + init { + Log.d(TAG, "init()") + isClickable = false + clipChildren = false + clipToPadding = false + + addView(backdrop, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + addView(container, LayoutParams(LayoutParams.MATCH_PARENT, 0)) + container.addView(childrenContainer, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + + doOnLayout { + Log.d(TAG, "doOnLayout -> hideContainerInstant()") + hideContainerInstant() + } + + backdrop.setOnClickListener { + Log.d(TAG, "backdrop onClick (isAnimating=$isAnimating, isPresented=$isPresented)") + if (!isAnimating && isPresented) dismiss() + } + + dragHelper = ViewDragHelper.create(this, 1f, object : ViewDragHelper.Callback() { + override fun tryCaptureView(child: View, pointerId: Int): Boolean { + val res = (child === container) && !isAnimating && allowDragNow + Log.d(TAG, "tryCaptureView: allowDragNow=$allowDragNow isAnimating=$isAnimating -> $res") + return res + } + + override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int = child.left + + override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { + val minTop = restTop + val maxTop = height + val clamped = top.coerceIn(minTop, maxTop) + return clamped + } + + override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { + updateBackdropForTop(top) + val dragging = (dragHelper.viewDragState == ViewDragHelper.STATE_DRAGGING) + if (dragging != isDraggingSheet) { + Log.d(TAG, "onViewPositionChanged: isDraggingSheet $isDraggingSheet -> $dragging, top=$top") + } + isDraggingSheet = dragging + } + + override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) { + val curTop = releasedChild.top + val shouldDismiss = (yvel > velocityDismissThreshold) || (curTop > restTop + dragRange / 2) + Log.d(TAG, "onViewReleased: top=$curTop, yvel=$yvel, shouldDismiss=$shouldDismiss") + if (shouldDismiss) { + settleTo(height) { onDismissAnimationEnd() } + } else { + settleTo(restTop) { } + } + isDraggingSheet = false + handingBackToChild = false + } + + override fun getViewVerticalDragRange(child: View): Int = dragRange + }) + + ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> + val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) + keyboardBottomInsetPx = ime.bottom + Log.d(TAG, "Insets IME bottom=$keyboardBottomInsetPx (isPresented=$isPresented)") + if (isPresented) adjustToCurrentContent(animated = true) + insets + } + } + + private class ChildTouchDelegate( + private val getOffset: () -> Int, + private val requestParentIntercept: (Boolean) -> Unit, + private val touchSlop: Int + ) : View.OnTouchListener { + + private var lastY = 0f + private var scrollEnabled = true + private var passedSlop = false + + fun setScrollEnabled(enabled: Boolean) { + if (enabled != scrollEnabled) { + Log.d("NativeSheetView", "ChildTouchDelegate.setScrollEnabled: $scrollEnabled -> $enabled") + } + scrollEnabled = enabled + } + + override fun onTouch(v: View, ev: MotionEvent): Boolean { + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { + lastY = ev.y + passedSlop = false + requestParentIntercept(true) + Log.d("NativeSheetView", "ChildTouchDelegate DOWN: requestDisallowIntercept(true)") + return false + } + + MotionEvent.ACTION_MOVE -> { + val dy = ev.y - lastY + if (!passedSlop && kotlin.math.abs(dy) > touchSlop) { + passedSlop = true + Log.d("NativeSheetView", "ChildTouchDelegate MOVE: passedSlop dy=$dy") + } + lastY = ev.y + + if (passedSlop && dy > 0f && getOffset() <= 0) { + Log.d("NativeSheetView", "ChildTouchDelegate MOVE: handoff to parent (dy>0, offset<=0) -> requestDisallowIntercept(false)") + requestParentIntercept(false) + } + + if (!scrollEnabled) { + val eat = passedSlop + if (eat) Log.d("NativeSheetView", "ChildTouchDelegate MOVE: eat (scroll disabled & passedSlop)") + return eat + } + + return false + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + Log.d("NativeSheetView", "ChildTouchDelegate ${if (ev.actionMasked==MotionEvent.ACTION_UP) "UP" else "CANCEL"}: let pass") + return false + } + + else -> return false + } + } + } + + private fun attachChildTouchListenerIfNeeded() { + val v = scrollableChild ?: return + if (childDelegate != null) return + + Log.d(TAG, "attachChildTouchListenerIfNeeded -> attach to ${v.javaClass.simpleName}") + childDelegate = ChildTouchDelegate( + getOffset = { getVerticalScrollOffset(v) }, + requestParentIntercept = { disallow -> + Log.d(TAG, "Child -> requestDisallowIntercept($disallow)") + v.parent?.requestDisallowInterceptTouchEvent(disallow) + }, + touchSlop = ViewConfiguration.get(context).scaledTouchSlop + ) + v.setOnTouchListener(childDelegate) + } + + private fun detachChildTouchListener() { + val v = scrollableChild ?: return + Log.d(TAG, "detachChildTouchListener from ${v.javaClass.simpleName}") + v.setOnTouchListener(null) + childDelegate = null + } + + private fun updateScrollEnabledForContent(currentSheetH: Int) { + val maxH = contentMaximumHeight() + val enableScroll = currentSheetH >= maxH + Log.d(TAG, "updateScrollEnabledForContent: sheetH=$currentSheetH maxH=$maxH -> scroll=${enableScroll}") + childDelegate?.setScrollEnabled(enableScroll) + } + + private fun recomputeHeights() { + ensureRefsIfMissing() + val hView = headerView + val cView = contentView + + var headerH = hView?.measuredHeight ?: 0 + if (headerH == 0) headerH = hView?.height ?: 0 + var contentH = cView?.measuredHeight ?: 0 + if (contentH == 0) contentH = cView?.height ?: 0 + + if (headerH == 0 && hView != null) { + headerH = safeMeasureIfNeeded(hView, width) + } + if (contentH == 0 && cView != null) { + contentH = safeMeasureIfNeeded(cView, width) + } + + headerHeight = headerH + contentHeight = contentH + Log.d(TAG, "recomputeHeights: header=$headerHeight content=$contentHeight") + } + + private fun currentContentHeight(): Int { + val scroll = scrollableChild + val h = when (scroll) { + is ScrollView, is NestedScrollView -> getScrollViewContentHeight(scroll) + else -> contentView?.measuredHeight?.coerceAtLeast(0) ?: 0 + } + Log.d(TAG, "currentContentHeight: $h (scrollable=${scroll?.javaClass?.simpleName})") + return h + } + + private fun contentMaximumHeight(): Int { + val insets = ViewCompat.getRootWindowInsets(this) + val sys = insets?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: Insets.NONE + val ime = insets?.getInsets(WindowInsetsCompat.Type.ime()) ?: Insets.NONE + + val safeTop = sys.top + val imeBottom = ime.bottom + + val h = height - safeTop - imeBottom + return max(h, 0) + } + + private fun calcSheetHeight(contentH: Int): Int = headerHeight + contentH + + fun setBackdropOpacity(value: Float) { + backdropMaxAlpha = value.coerceIn(0f, 1f) + backdrop.alpha = backdrop.alpha.coerceAtMost(backdropMaxAlpha) + Log.d(TAG, "setBackdropOpacity: $backdropMaxAlpha") + } + + fun setCornerRadius(valueDp: Float) { + val px = valueDp * resources.displayMetrics.density + if (px == cornerRadiusPx) return + cornerRadiusPx = px + container.invalidateOutline() + Log.d(TAG, "setCornerRadiusPx: $cornerRadiusPx") + } + + fun present(optionalHeightPx: Int? = null) { + if (isAnimating || isPresented) { + Log.d(TAG, "present: skip (isAnimating=$isAnimating isPresented=$isPresented)") + return + } + if (width == 0 || height == 0) { + Log.d(TAG, "present: defer (no size yet)") + post { present(optionalHeightPx) } + return + } + + recomputeHeights() + if (headerHeight == 0) { + post { + if (isPresented) adjustToCurrentContent(true) + } + } + + val contentH = optionalHeightPx ?: currentContentHeight() + val sheetH = calcSheetHeight(contentH) + if (sheetH <= 0) { + Log.w(TAG, "present: sheetH<=0, bail") + return + } + + cachedHeightPx = sheetH + + val frame = frameForHeight(sheetH) + val targetH = frame.height() + + val wSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY) + val hSpec = MeasureSpec.makeMeasureSpec(targetH, MeasureSpec.EXACTLY) + container.measure(wSpec, hSpec) + + updateGeometryForHeight(targetH) + placeOffscreenPreservingHeightByLayout() + + ensureContentConfiguredOnce() + + isAnimating = true + isPresented = true + Log.d(TAG, "present: start -> targetH=$targetH restTop=$restTop") + + animateBackdrop(to = backdropMaxAlpha) + settleTo(restTop) { + isAnimating = false + Log.d(TAG, "present: finished at top=$restTop") + dispatchEvent("onAppeared", Arguments.createMap()) + } + } + + fun adjustToCurrentContent(animated: Boolean) { + ensureRefsIfMissing() + if (contentView == null) { + Log.d(TAG, "adjustToCurrentContent: no contentView") + return + } + if (width == 0 || height == 0) return + + recomputeHeights() + + val contentH = currentContentHeight() + val newSheetH = calcSheetHeight(contentH) + if (newSheetH <= 0) return + + val newFrame = frameForHeight(newSheetH) + val newH = newFrame.height() + val sameTop = (container.top == newFrame.top) + val sameH = (container.height == newH) + + if (sameTop && sameH) { + Log.d(TAG, "adjustToCurrentContent: nothing to change") + return + } + + cachedHeightPx = newSheetH + Log.d(TAG, "adjustToCurrentContent: newH=$newH top=${newFrame.top} animated=$animated isAnimating=$isAnimating") + applyContainerFrame(newFrame, animated && !isAnimating) + } + + fun dismiss() { + if (!isPresented || isAnimating) { + Log.d(TAG, "dismiss: skip (isPresented=$isPresented isAnimating=$isAnimating)") + return + } + isAnimating = true + isPresented = false + Log.d(TAG, "dismiss: start") + animateBackdrop(to = 0f) + settleTo(height) { + isAnimating = false + onDismissAnimationEnd() + } + } + + private fun safeMeasureIfNeeded(v: View?, parentWidth: Int): Int { + if (v == null) return 0 + val h = if (v.measuredHeight > 0) v.measuredHeight else v.height + if (h > 0) return h + val wSpec = MeasureSpec.makeMeasureSpec(parentWidth.coerceAtLeast(0), MeasureSpec.EXACTLY) + val hSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + return try { + v.measure(wSpec, hSpec) + v.measuredHeight + } catch (_: Throwable) { + 0 + } + } + + private fun frameForHeight(sheetHeight: Int): android.graphics.Rect { + val maxH = contentMaximumHeight() + val adjustedH = min(maxH, sheetHeight) + val top = height - adjustedH + return android.graphics.Rect(0, top, width, top + adjustedH) + } + + private fun applyContainerFrame(newFrame: android.graphics.Rect, animated: Boolean) { + val newH = newFrame.height() + val newTop = newFrame.top + val oldTop = container.top + val oldH = container.height + + if (oldTop == newTop && oldH == newH) return + + val wSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY) + val hSpec = MeasureSpec.makeMeasureSpec(newH, MeasureSpec.EXACTLY) + container.measure(wSpec, hSpec) + + freezeBackdrop() + computeGeometryForHeight(newH) + + if (!animated || isAnimating) { + layoutContainer(newTop, newH) + updateBackdropForTop(container.top) + updateScrollEnabledForContent(newH) + unfreezeBackdrop() + Log.d(TAG, "applyContainerFrame: instant -> top=$newTop h=$newH") + return + } + + layoutContainer(oldTop, newH) + updateBackdropForTop(container.top) + + val started = dragHelper.smoothSlideViewTo(container, container.left, newTop) + Log.d(TAG, "applyContainerFrame: animated -> start=$started from=$oldTop to=$newTop h=$newH") + if (started) { + postOnAnimation { + continueSettling { + updateScrollEnabledForContent(newH) + unfreezeBackdrop() + Log.d(TAG, "applyContainerFrame: animated finished at top=${container.top}") + } + } + } else { + layoutContainer(newTop, newH) + updateBackdropForTop(container.top) + updateScrollEnabledForContent(newH) + unfreezeBackdrop() + Log.d(TAG, "applyContainerFrame: fallback layout -> top=$newTop h=$newH") + } + } + + private fun computeGeometryForHeight(targetH: Int) { + restTop = height - targetH + dragRange = height - restTop + invDragRange = if (dragRange > 0) 1f / dragRange else 0f + Log.d(TAG, "computeGeometryForHeight: targetH=$targetH restTop=$restTop dragRange=$dragRange") + } + + private fun layoutContainer(top: Int, h: Int) { + container.layout(0, top, width, top + h) + } + + private fun hideContainerInstant() { + container.layout(0, height, width, height) + backdrop.alpha = 0f + Log.d(TAG, "hideContainerInstant()") + } + + private fun placeOffscreenPreservingHeightByLayout() { + val h = container.height + container.layout(0, height, width, height + h) + updateBackdropForTop(container.top) + Log.d(TAG, "placeOffscreenPreservingHeightByLayout: h=$h") + } + + private fun updateGeometryForHeight(targetH: Int) { + restTop = height - targetH + dragRange = height - restTop + invDragRange = if (dragRange > 0) 1f / dragRange else 0f + container.layout(0, restTop, width, restTop + targetH) + updateBackdropForTop(container.top) + Log.d(TAG, "updateGeometryForHeight: targetH=$targetH restTop=$restTop") + } + + private fun freezeBackdrop() { + backdropFrozenAlpha = backdrop.alpha + backdropFrozen = true + Log.d(TAG, "freezeBackdrop: alpha=$backdropFrozenAlpha") + } + + private fun unfreezeBackdrop() { + backdropFrozen = false + updateBackdropForTop(container.top) + Log.d(TAG, "unfreezeBackdrop()") + } + + private fun updateBackdropForTop(curTop: Int) { + if (backdropFrozen) { + backdrop.alpha = backdropFrozenAlpha + return + } + val span = dragRange + val t = if (span > 0) ((curTop - restTop).coerceIn(0, span)) * invDragRange else 1f + val a = lerp(backdropMaxAlpha, 0f, t) + backdrop.alpha = a + } + + private fun animateBackdrop(to: Float) { + val clamped = to.coerceIn(0f, backdropMaxAlpha) + Log.d(TAG, "animateBackdrop: to=$clamped") + backdrop.animate() + .alpha(clamped) + .setDuration(240) + .setInterpolator(spring) + .start() + } + + private fun settleTo(targetTop: Int, end: (() -> Unit)? = null) { + val started = dragHelper.smoothSlideViewTo(container, container.left, targetTop) + Log.d(TAG, "settleTo: targetTop=$targetTop started=$started") + if (started) { + postOnAnimation { continueSettling(end) } + } else { + isDraggingSheet = false + handingBackToChild = false + end?.invoke() + } + } + + private fun continueSettling(end: (() -> Unit)?) { + if (dragHelper.continueSettling(true)) { + postOnAnimation { continueSettling(end) } + } else { + isDraggingSheet = false + handingBackToChild = false + end?.invoke() + } + } + + private fun onDismissAnimationEnd() { + Log.d(TAG, "onDismissAnimationEnd -> dispatch onDismissed") + dispatchEvent("onDismissed", Arguments.createMap()) + } + + private fun lerp(a: Float, b: Float, t: Float) = a + (b - a) * t.coerceIn(0f, 1f) + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + val x = ev.x.toInt() + val y = ev.y.toInt() + if (!hitInContainer(x, y)) return super.onInterceptTouchEvent(ev) + + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downY = ev.y + draggingDownGesture = false + allowDragNow = false + isDraggingSheet = false + handingBackToChild = false + dragHelper.shouldInterceptTouchEvent(ev) + Log.d(TAG, "onIntercept: DOWN in container") + return false + } + MotionEvent.ACTION_MOVE -> { + val dy = ev.y - downY + val passedSlop = abs(dy) > touchSlop + val canUp = canChildScrollUp() + if (passedSlop) { + draggingDownGesture = dy > 0 + allowDragNow = draggingDownGesture && !canUp && !isAnimating + Log.d(TAG, "onIntercept: MOVE passedSlop dy=$dy draggingDown=$draggingDownGesture canUp=$canUp allowDragNow=$allowDragNow") + dragHelper.shouldInterceptTouchEvent(ev) + if (allowDragNow) { + isDraggingSheet = true + handingBackToChild = false + return true + } + return false + } else { + dragHelper.shouldInterceptTouchEvent(ev) + return false + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + allowDragNow = false + isDraggingSheet = false + handingBackToChild = false + dragHelper.shouldInterceptTouchEvent(ev) + Log.d(TAG, "onIntercept: ${if (ev.actionMasked==MotionEvent.ACTION_UP) "UP" else "CANCEL"}") + return false + } + } + + dragHelper.shouldInterceptTouchEvent(ev) + return false + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (isDraggingSheet && !handingBackToChild && event.actionMasked == MotionEvent.ACTION_MOVE) { + val dyNow = event.y - downY + val atRest = (container.top == restTop) + val child = scrollableChild + val childCanUp = canChildScrollUp() + if (dyNow < -touchSlop && atRest && child != null && childCanUp) { + Log.d(TAG, "onTouchEvent: handOffToChild (dyNow=$dyNow atRest=$atRest childCanUp=$childCanUp)") + handOffToChild(child, event) + allowDragNow = false + isDraggingSheet = false + return false + } + } + + dragHelper.processTouchEvent(event) + val hit = hitInContainer(event.x.toInt(), event.y.toInt()) + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + Log.d(TAG, "onTouchEvent: DOWN hitInContainer=$hit") + } + return hit + } + + private fun hitInContainer(x: Int, y: Int): Boolean { + val l = container.left + val t = container.top + val r = container.right + val b = container.bottom + return x in l..r && y in t..b + } + + private fun getTestId(view: View): String? { + (view.getTag(ReactR.id.react_test_id) as? String)?.let { return it } + (view.getTag(ReactR.id.view_tag_native_id) as? String)?.let { return it } + return null + } + + private fun findAndroidScrollableIn(root: View?): View? { + if (root == null) return null + if (root is ScrollView || root is NestedScrollView || root is RecyclerView || root is ListView) { + return root + } + if (root is ViewGroup) { + for (i in 0 until root.childCount) { + findAndroidScrollableIn(root.getChildAt(i))?.let { return it } + } + } + return null + } + + private fun ensureRefsIfMissing() { + if (headerView == null || contentView == null) { + captureRefsFromViewIfNeeded(childrenContainer) + } + } + + private fun captureRefsFromViewIfNeeded(root: View?) { + if (root == null) return + val beforeHeader = headerView != null + val beforeContent = contentView != null + + scanForTargetsFromView(root, depth = 0, maxDepth = SUBTREE_SCAN_MAX_DEPTH) + + if (!beforeHeader && headerView != null) { + Log.d(TAG, "captureRefs: headerView found (${headerView!!.javaClass.simpleName})") + } + if (!beforeContent && contentView != null) { + Log.d(TAG, "captureRefs: contentView found (${contentView!!.javaClass.simpleName})") + } + + contentView?.let { v -> + if (scrollableChild == null) { + scrollableChild = findAndroidScrollableIn(v) + if (scrollableChild != null) { + Log.d(TAG, "captureRefs: scrollableChild=${scrollableChild!!.javaClass.simpleName}") + } + } + scrollContentChild = when (val s = scrollableChild) { + is ScrollView -> s.getChildAt(0) + is NestedScrollView -> s.getChildAt(0) + is RecyclerView -> null + else -> (v as? ViewGroup)?.getChildAt(0) + } + ensureContentConfiguredOnce() + } + } + + private fun shouldContinueScanning(): Boolean = + (!headerScanExhausted && headerView == null) || (!contentScanExhausted && contentView == null) + + private fun scanForTargetsFromView(view: View, depth: Int, maxDepth: Int) { + if (!shouldContinueScanning()) return + + val id = getTestId(view) + if (id != null) { + if (!headerScanExhausted && headerView == null && id == "sheet-header") { + headerView = view + headerScanExhausted = true + Log.d(TAG, "scan: headerView tagged by testID") + if (!headerLayoutListenerAttached) { + headerView?.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + if (isPresented) { + Log.d(TAG, "header onLayout -> adjustToCurrentContent(true)") + adjustToCurrentContent(true) + } + } + headerLayoutListenerAttached = true + } + } + if (!contentScanExhausted && contentView == null && id == "sheet-content") { + contentView = view + contentScanExhausted = true + Log.d(TAG, "scan: contentView tagged by testID") + } + } + + if (!shouldContinueScanning() || depth >= maxDepth) return + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + if (!shouldContinueScanning()) return + scanForTargetsFromView(view.getChildAt(i), depth + 1, maxDepth) + } + } + } + + private fun installTapProtectors() { + headerView?.setOnTouchListener { v, e -> + if (e.actionMasked == MotionEvent.ACTION_DOWN) { + Log.d(TAG, "tap-protector header: requestDisallowIntercept(true)") + v.parent?.requestDisallowInterceptTouchEvent(true) + } + false + } + + if (scrollableChild == null) { + contentView?.setOnTouchListener { v, e -> + if (e.actionMasked == MotionEvent.ACTION_DOWN) { + Log.d(TAG, "tap-protector content(no-scroll): requestDisallowIntercept(true)") + v.parent?.requestDisallowInterceptTouchEvent(true) + } + false + } + } + } + + private fun ensureContentConfiguredOnce() { + attachChildTouchListenerIfNeeded() + installTapProtectors() + if (contentLayoutListenerAdded) return + + val contentChild = scrollContentChild + if (contentChild != null) { + Log.d(TAG, "ensureContentConfiguredOnce: add listeners to scrollContentChild") + contentChild.addOnLayoutChangeListener(contentLayoutChangeListener) + contentGlobalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { + if (!isPresented) return@OnGlobalLayoutListener + Log.d(TAG, "content globalLayout -> adjustToCurrentContent(true)") + adjustToCurrentContent(true) + } + contentChild.viewTreeObserver.addOnGlobalLayoutListener(contentGlobalLayoutListener) + contentLayoutListenerAdded = true + return + } + + val cv = contentView + if (cv != null) { + Log.d(TAG, "ensureContentConfiguredOnce: add listeners to contentView") + cv.addOnLayoutChangeListener(contentLayoutChangeListener) + contentGlobalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { + if (!isPresented) return@OnGlobalLayoutListener + Log.d(TAG, "contentView globalLayout -> adjustToCurrentContent(true)") + adjustToCurrentContent(true) + } + cv.viewTreeObserver.addOnGlobalLayoutListener(contentGlobalLayoutListener) + contentLayoutListenerAdded = true + } + } + + private val contentLayoutChangeListener = OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + if (!isPresented) return@OnLayoutChangeListener + Log.d(TAG, "content onLayoutChange -> adjustToCurrentContent(true)") + adjustToCurrentContent(animated = true) + } + + private fun removeContentObservers() { + if (!contentLayoutListenerAdded) return + Log.d(TAG, "removeContentObservers()") + scrollContentChild?.let { cc -> + try { cc.removeOnLayoutChangeListener(contentLayoutChangeListener) } catch (_: Throwable) {} + try { contentGlobalLayoutListener?.let { l -> cc.viewTreeObserver.removeOnGlobalLayoutListener(l) } } catch (_: Throwable) {} + } + contentView?.let { cv -> + try { cv.removeOnLayoutChangeListener(contentLayoutChangeListener) } catch (_: Throwable) {} + try { contentGlobalLayoutListener?.let { l -> cv.viewTreeObserver.removeOnGlobalLayoutListener(l) } } catch (_: Throwable) {} + } + contentLayoutListenerAdded = false + contentGlobalLayoutListener = null + } + + private fun dispatchEvent(eventName: String, payload: WritableMap? = null) { + val reactContext = UIManagerHelper.getReactContext(this) ?: return + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return + Log.d(TAG, "dispatchEvent: $eventName") + dispatcher.dispatchEvent(SimpleEvent(id, eventName, payload)) + } + + private class SimpleEvent( + viewId: Int, + private val name: String, + private val map: WritableMap? + ) : Event(viewId) { + override fun getEventName(): String = name + override fun canCoalesce(): Boolean = false + override fun dispatch(rctEventEmitter: RCTEventEmitter) { + rctEventEmitter.receiveEvent(viewTag, name, map) + } + } + + internal fun onChildMounted(child: View) { + Log.d(TAG, "onChildMounted: ${child.javaClass.simpleName}") + if (shouldContinueScanning()) captureRefsFromViewIfNeeded(child) + } + + internal fun requestFullRescan() { + if (isRescanning) return + isRescanning = true + Log.d(TAG, "requestFullRescan()") + + detachChildTouchListener() + removeContentObservers() + scrollableChild = null + scrollContentChild = null + headerView = null + contentView = null + headerScanExhausted = false + contentScanExhausted = false + headerLayoutListenerAttached = false + + post { + captureRefsFromViewIfNeeded(childrenContainer) + isRescanning = false + if (isPresented && contentView != null) { + Log.d(TAG, "requestFullRescan -> adjustToCurrentContent(true)") + adjustToCurrentContent(animated = true) + } + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + Log.d(TAG, "onAttachedToWindow") + viewTreeObserver.addOnGlobalLayoutListener(layoutListener) + if (!isPresented && !isAnimating) { + doOnLayout { post { present(null) } } + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + Log.d(TAG, "onDetachedFromWindow") + viewTreeObserver.removeOnGlobalLayoutListener(layoutListener) + detachChildTouchListener() + removeContentObservers() + headerView = null + contentView = null + scrollableChild = null + scrollContentChild = null + headerScanExhausted = false + contentScanExhausted = false + headerLayoutListenerAttached = false + cachedHeightPx = 0 + keyboardBottomInsetPx = 0 + isAnimating = false + isPresented = false + hideContainerInstant() + backdrop.alpha = 0f + } + + private fun canChildScrollUp(): Boolean { + val v = scrollableChild + val res = v?.canScrollVertically(-1) == true + return res + } + + private fun getVerticalScrollOffset(v: View?): Int { + return when (v) { + is ScrollView -> v.scrollY + is NestedScrollView -> v.scrollY + is RecyclerView -> v.computeVerticalScrollOffset() + is ListView -> { + val first = v.firstVisiblePosition + val child = v.getChildAt(0) + if (child == null) 0 else first * child.height - child.top + } + else -> 0 + } + } + + private fun getScrollViewContentHeight(scroll: View?): Int { + return when (scroll) { + is ScrollView -> scroll.getChildAt(0)?.measuredHeight ?: 0 + is NestedScrollView -> scroll.getChildAt(0)?.measuredHeight ?: 0 + else -> { + val vg = (contentView as? ViewGroup) + val child = vg?.getChildAt(0) + child?.measuredHeight ?: (contentView?.measuredHeight ?: 0) + } + } + } + + private fun handOffToChild(child: View, srcEvent: MotionEvent) { + handingBackToChild = true + Log.d(TAG, "handOffToChild -> ${child.javaClass.simpleName}") + child.parent?.requestDisallowInterceptTouchEvent(true) + + val cancel = MotionEvent.obtain(srcEvent.downTime, srcEvent.eventTime, + MotionEvent.ACTION_CANCEL, srcEvent.x, srcEvent.y, srcEvent.metaState) + super.onTouchEvent(cancel) + dragHelper.cancel() + cancel.recycle() + + val locChild = IntArray(2) + child.getLocationOnScreen(locChild) + val childX = srcEvent.rawX - locChild[0] + val childY = srcEvent.rawY - locChild[1] + val now = SystemClock.uptimeMillis() + + val down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, childX, childY, srcEvent.metaState) + child.dispatchTouchEvent(down) + down.recycle() + + val move = MotionEvent.obtain(now, now, MotionEvent.ACTION_MOVE, childX, childY, srcEvent.metaState) + child.dispatchTouchEvent(move) + move.recycle() + } } diff --git a/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt b/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt index 57a5120..943fc1d 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt @@ -1,40 +1,69 @@ package com.nativesheet -import android.graphics.Color +import android.view.View +import android.view.ViewGroup import com.facebook.react.module.annotations.ReactModule -import com.facebook.react.uimanager.SimpleViewManager import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.uimanager.annotations.ReactProp -import com.facebook.react.viewmanagers.NativeSheetViewManagerInterface import com.facebook.react.viewmanagers.NativeSheetViewManagerDelegate +import com.facebook.react.viewmanagers.NativeSheetViewManagerInterface @ReactModule(name = NativeSheetViewManager.NAME) -class NativeSheetViewManager : SimpleViewManager(), +class NativeSheetViewManager : + ViewGroupManager(), NativeSheetViewManagerInterface { - private val mDelegate: ViewManagerDelegate - init { - mDelegate = NativeSheetViewManagerDelegate(this) + private val delegate: ViewManagerDelegate = NativeSheetViewManagerDelegate(this) + override fun getDelegate(): ViewManagerDelegate = delegate + + override fun getName(): String = NAME + + override fun createViewInstance(context: ThemedReactContext): NativeSheetView = + NativeSheetView(context) + + @ReactProp(name = "backdropOpacity") + override fun setBackdropOpacity(view: NativeSheetView, value: Float) { + view.setBackdropOpacity(value) + } + + @ReactProp(name = "cornerRadius") + override fun setCornerRadius(view: NativeSheetView, value: Float) { + view.setCornerRadius(value) + } + + override fun dismiss(view: NativeSheetView) { + view.dismiss() } - override fun getDelegate(): ViewManagerDelegate? { - return mDelegate + override fun onAfterUpdateTransaction(view: NativeSheetView) { + super.onAfterUpdateTransaction(view) + view.requestFullRescan() } - override fun getName(): String { - return NAME + override fun addView(parent: NativeSheetView, child: View, index: Int) { + val container: ViewGroup = parent.childrenContainer + val safeIndex = index.coerceIn(0, container.childCount) + container.addView(child, safeIndex) + parent.onChildMounted(child) } - public override fun createViewInstance(context: ThemedReactContext): NativeSheetView { - return NativeSheetView(context) + override fun getChildAt(parent: NativeSheetView, index: Int): View { + return parent.childrenContainer.getChildAt(index) } - @ReactProp(name = "color") - override fun setColor(view: NativeSheetView?, color: String?) { - view?.setBackgroundColor(Color.parseColor(color)) + override fun getChildCount(parent: NativeSheetView): Int { + return parent.childrenContainer.childCount } + override fun removeViewAt(parent: NativeSheetView, index: Int) { + parent.childrenContainer.removeViewAt(index) + parent.requestFullRescan() + } + + override fun needsCustomLayoutForChildren(): Boolean = false + companion object { const val NAME = "NativeSheetView" } diff --git a/example/src/App.tsx b/example/src/App.tsx index a215b04..5bdbc16 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,3 +1,8 @@ +import { memo, useRef, useState, type PropsWithChildren } from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ScreenStack, ScreenStackItem } from 'react-native-screens'; +import { NativeSheetView } from '@sigmela/native-sheet'; +import { ExampleSheet } from './ExampleSheet'; import { SheetsContext, useSheets, @@ -5,25 +10,55 @@ import { createSheet, DismissContext, } from './useSheets'; -import { memo, useRef, useState, type PropsWithChildren } from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { ScreenStack, ScreenStackItem } from 'react-native-screens'; -import { Commands, NativeSheetView } from '@sigmela/native-sheet'; -import { ExampleSheet } from './ExampleSheet'; const HomeScreen = () => { const { sheets, setSheets } = useSheets(); + const [isVisible, setIsVisible] = useState(false); + const ref = useRef>(null); + return ( + Home + { + // console.time('SCREENS'); setSheets([...sheets, createSheet(ExampleSheet)]); }} > Open Screens Modal + { + // console.time('LOCAL'); + // Commands.present(ref.current!); + setIsVisible(true); + }} + > + Open LOCAL Modal + + {isVisible && ( + + { + setIsVisible(false); + }} + onAppeared={() => { + console.log('onAppeared'); + }} + // onDismissed={onDismissed}d + > + {} }}> + + + + + )} ); }; @@ -31,16 +66,13 @@ const HomeScreen = () => { export default function App() { const [sheets, setSheets] = useState([]); - const dismiss = (id: number) => { - setSheets((sheetsState) => sheetsState.filter((s) => s.id !== id)); - }; - return ( - + {}} style={StyleSheet.absoluteFill} headerConfig={{ title: 'Home' }} > @@ -51,12 +83,10 @@ export default function App() { key={`sheet-item-${sheet.id}`} screenId={`sheet-${sheet.id}`} onDismissed={() => { - setSheets((sheetsState) => - sheetsState.filter((s) => s.id !== sheet.id) - ); + setSheets((sheets) => sheets.filter((s) => s.id !== sheet.id)); }} > - + ))} @@ -88,17 +118,8 @@ const StackScreenSheetItem = memo((props: StackScreenSheetItemProps) => { onDismissed(); }} > - - ref.current && Commands.dismiss(ref.current), - }} - > + + {} }}> {children} From 67353feec48382ef54c1881c41e5314c8b883bf4 Mon Sep 17 00:00:00 2001 From: bogoslavskiy Date: Fri, 10 Oct 2025 04:09:06 +0300 Subject: [PATCH 02/12] fix(android): edgeToEdgeEnabled && implementation recyclerview --- android/build.gradle | 3 +++ example/android/gradle.properties | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/android/build.gradle b/android/build.gradle index 3db12d1..c5b6ced 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -74,4 +74,7 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "androidx.customview:customview:1.1.0" + implementation "androidx.core:core-ktx:1.12.0" + implementation "androidx.recyclerview:recyclerview:1.3.2" } diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 9afe615..a6de6f6 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -41,4 +41,4 @@ hermesEnabled=true # Use this property to enable edge-to-edge display support. # This allows your app to draw behind system bars for an immersive UI. # Note: Only works with ReactActivity and should not be used with custom Activity. -edgeToEdgeEnabled=false +edgeToEdgeEnabled=true From e55d384aec151d6eabded9578f161246ca2f0c5b Mon Sep 17 00:00:00 2001 From: bogoslavskiy Date: Fri, 10 Oct 2025 14:50:15 +0300 Subject: [PATCH 03/12] fix(android): replace testId with nativeId for detect slot && wrap safe area --- .../java/com/nativesheet/NativeSheetView.kt | 1 - example/src/App.tsx | 49 ++++++++++--------- example/src/ExampleSheet.tsx | 15 +++--- 3 files changed, 33 insertions(+), 32 deletions(-) diff --git a/android/src/main/java/com/nativesheet/NativeSheetView.kt b/android/src/main/java/com/nativesheet/NativeSheetView.kt index 5505df7..0f49f6f 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetView.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetView.kt @@ -683,7 +683,6 @@ class NativeSheetView @JvmOverloads constructor( } private fun getTestId(view: View): String? { - (view.getTag(ReactR.id.react_test_id) as? String)?.let { return it } (view.getTag(ReactR.id.view_tag_native_id) as? String)?.let { return it } return null } diff --git a/example/src/App.tsx b/example/src/App.tsx index 5bdbc16..1c6c62c 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,6 +1,7 @@ import { memo, useRef, useState, type PropsWithChildren } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ScreenStack, ScreenStackItem } from 'react-native-screens'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; import { NativeSheetView } from '@sigmela/native-sheet'; import { ExampleSheet } from './ExampleSheet'; import { @@ -67,30 +68,32 @@ export default function App() { const [sheets, setSheets] = useState([]); return ( - - - {}} - style={StyleSheet.absoluteFill} - headerConfig={{ title: 'Home' }} - > - - - {sheets.map((sheet) => ( - { - setSheets((sheets) => sheets.filter((s) => s.id !== sheet.id)); - }} + + + + {}} + style={StyleSheet.absoluteFill} + headerConfig={{ title: 'Home' }} > - - - ))} - - + + + {sheets.map((sheet) => ( + { + setSheets((sheets) => sheets.filter((s) => s.id !== sheet.id)); + }} + > + + + ))} + + + ); } diff --git a/example/src/ExampleSheet.tsx b/example/src/ExampleSheet.tsx index 1274446..5c229ff 100644 --- a/example/src/ExampleSheet.tsx +++ b/example/src/ExampleSheet.tsx @@ -5,15 +5,17 @@ import { Text } from 'react-native'; import { ScrollView } from 'react-native'; import { StyleSheet } from 'react-native'; import { useSheets, createSheet, useDismiss } from './useSheets'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; export const ExampleSheet = () => { const [list, setList] = useState([{ title: 1 }]); const { setSheets, sheets } = useSheets(); const { dismiss } = useDismiss(); + const insets = useSafeAreaInsets(); return ( - <> - + + {Platform.OS === 'android' && ( { {list.map((i, key) => ( @@ -65,7 +64,7 @@ export const ExampleSheet = () => { ))} - + ); }; From adbe9bd9013f832660c4c0e4c8ff9fce6abc09cb Mon Sep 17 00:00:00 2001 From: bogoslavskiy Date: Fri, 10 Oct 2025 16:57:55 +0300 Subject: [PATCH 04/12] fix(android): screen presenation --- .../java/com/nativesheet/NativeSheetView.kt | 2 +- example/src/App.tsx | 21 +++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/nativesheet/NativeSheetView.kt b/android/src/main/java/com/nativesheet/NativeSheetView.kt index 0f49f6f..ab6bd2d 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetView.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetView.kt @@ -903,7 +903,7 @@ class NativeSheetView @JvmOverloads constructor( Log.d(TAG, "onAttachedToWindow") viewTreeObserver.addOnGlobalLayoutListener(layoutListener) if (!isPresented && !isAnimating) { - doOnLayout { post { present(null) } } + post { present(null) } } } diff --git a/example/src/App.tsx b/example/src/App.tsx index 1c6c62c..666ca74 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -2,7 +2,7 @@ import { memo, useRef, useState, type PropsWithChildren } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ScreenStack, ScreenStackItem } from 'react-native-screens'; import { SafeAreaProvider } from 'react-native-safe-area-context'; -import { NativeSheetView } from '@sigmela/native-sheet'; +import { Commands, NativeSheetView } from '@sigmela/native-sheet'; import { ExampleSheet } from './ExampleSheet'; import { SheetsContext, @@ -114,15 +114,27 @@ const StackScreenSheetItem = memo((props: StackScreenSheetItemProps) => { headerConfig={headerConfig} style={StyleSheet.absoluteFill} key={`${screenId}-sheet-item`} - contentStyle={styles.content} screenId={screenId} stackAnimation="none" + gestureEnabled={false} onDismissed={() => { onDismissed(); }} > - - {} }}> + { + onDismissed(); + console.log('onDismissed'); + }} + onAppeared={() => { + console.log('onAppeared'); + }} + > + Commands.dismiss(ref.current!) }} + > {children} @@ -135,6 +147,7 @@ const styles = StyleSheet.create({ flex: 1, }, content: { + flex: 1, backgroundColor: 'transparent', }, From 03d42bfcc7517836b4a9dab6c8134cf66343486c Mon Sep 17 00:00:00 2001 From: bogoslavskiy Date: Sat, 11 Oct 2025 22:25:47 +0300 Subject: [PATCH 05/12] fix(android): improve mesure logic and add touch wrapper for react view --- .../java/com/nativesheet/NativeSheetView.kt | 453 +++++++++--------- .../com/nativesheet/NativeSheetViewManager.kt | 28 +- .../main/java/com/nativesheet/RootChildren.kt | 80 ++++ 3 files changed, 325 insertions(+), 236 deletions(-) create mode 100644 android/src/main/java/com/nativesheet/RootChildren.kt diff --git a/android/src/main/java/com/nativesheet/NativeSheetView.kt b/android/src/main/java/com/nativesheet/NativeSheetView.kt index ab6bd2d..b2a5bb3 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetView.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetView.kt @@ -4,6 +4,7 @@ import android.animation.TimeInterpolator import android.content.Context import android.graphics.Color import android.graphics.Outline +import android.graphics.Path import android.os.SystemClock import android.util.AttributeSet import android.util.Log @@ -19,17 +20,16 @@ import androidx.core.view.doOnLayout import androidx.core.widget.NestedScrollView import androidx.customview.widget.ViewDragHelper import androidx.recyclerview.widget.RecyclerView +import com.facebook.react.R as ReactR import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.WritableMap import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.events.Event +import com.facebook.react.uimanager.events.EventDispatcher import com.facebook.react.uimanager.events.RCTEventEmitter -import com.facebook.react.views.view.ReactViewGroup import kotlin.math.abs import kotlin.math.max import kotlin.math.min -import com.facebook.react.R as ReactR -import android.graphics.Path class NativeSheetView @JvmOverloads constructor( context: Context, @@ -38,36 +38,76 @@ class NativeSheetView @JvmOverloads constructor( ) : FrameLayout(context, attrs, defStyleAttr) { private val TAG = "NativeSheetView" + private var loggingEnabled = false + fun setLoggingEnabled(enabled: Boolean) { loggingEnabled = enabled } + private inline fun logd(msg: String) { if (loggingEnabled) Log.d(TAG, msg) } + private inline fun logw(msg: String) { if (loggingEnabled) Log.w(TAG, msg) } + + private class SafeFrameLayout(ctx: Context) : FrameLayout(ctx) { + override fun setLayoutParams(params: ViewGroup.LayoutParams?) { + val safe = when (params) { + null -> FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + is FrameLayout.LayoutParams -> params + is ViewGroup.MarginLayoutParams -> FrameLayout.LayoutParams(params) + else -> FrameLayout.LayoutParams(params) + } + super.setLayoutParams(safe) + } + } + private class SafeView(ctx: Context) : View(ctx) { + override fun setLayoutParams(params: ViewGroup.LayoutParams?) { + val safe = when (params) { + null -> FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + is FrameLayout.LayoutParams -> params + is ViewGroup.MarginLayoutParams -> FrameLayout.LayoutParams(params) + else -> FrameLayout.LayoutParams(params) + } + super.setLayoutParams(safe) + } + } + + private data class Frame(val top: Int, val height: Int) - private val backdrop = View(context).apply { + private val outlinePath = Path() + private var lastOutlineW = -1 + private var lastOutlineH = -1 + private var lastCorner = -1f + private fun updateOutlinePathIfNeeded(w: Int, h: Int, r: Float) { + if (w == lastOutlineW && h == lastOutlineH && r == lastCorner) return + outlinePath.reset() + outlinePath.addRoundRect( + 0f, 0f, w.toFloat(), h.toFloat(), + floatArrayOf(r, r, r, r, 0f, 0f, 0f, 0f), + Path.Direction.CW + ) + lastOutlineW = w; lastOutlineH = h; lastCorner = r + container.invalidateOutline() + } + + private val backdrop = SafeView(context).apply { setBackgroundColor(Color.BLACK) isClickable = true alpha = 0f } - private val container = FrameLayout(context).apply { + private val container = SafeFrameLayout(context).apply { clipToOutline = true outlineProvider = object : ViewOutlineProvider() { override fun getOutline(view: View, outline: Outline) { - val path = Path().apply { - addRoundRect( - 0f, 0f, view.width.toFloat(), view.height.toFloat(), - floatArrayOf( - cornerRadiusPx, cornerRadiusPx, - cornerRadiusPx, cornerRadiusPx, - 0f, 0f, - 0f, 0f - ), - Path.Direction.CW - ) - } - outline.setConvexPath(path) + updateOutlinePathIfNeeded(view.width, view.height, cornerRadiusPx) + outline.setConvexPath(outlinePath) } } setBackgroundColor(Color.WHITE) } - internal val childrenContainer = ReactViewGroup(context) + internal val childrenContainer = RootChildren(context) private var isRescanning = false private var backdropFrozen = false @@ -80,8 +120,6 @@ class NativeSheetView @JvmOverloads constructor( private var backdropMaxAlpha = 0.5f private var cornerRadiusPx = 16f * resources.displayMetrics.density - private var keyboardBottomInsetPx = 0 - private var headerHeight = 0 private var contentHeight = 0 @@ -89,7 +127,7 @@ class NativeSheetView @JvmOverloads constructor( private var restTop = 0 private var dragRange = 0 private var invDragRange = 0f - private val velocityDismissThreshold = 1200f + private val velocityDismissThresholdPxPerSec = 1200f * resources.displayMetrics.density private var downY = 0f private var draggingDownGesture = false @@ -116,64 +154,57 @@ class NativeSheetView @JvmOverloads constructor( private val SUBTREE_SCAN_MAX_DEPTH = 20 + private var adjustPosted = false + private val layoutListener = ViewTreeObserver.OnGlobalLayoutListener { if (isPresented) { - Log.d(TAG, "GlobalLayout -> adjustToCurrentContent(animated=true)") - adjustToCurrentContent(animated = true) + logd("GlobalLayout -> requestAdjust(animated=true)") + requestAdjust(animated = true) } } private val spring: TimeInterpolator = PathInterpolator(0.2f, 0f, 0f, 1f) init { - Log.d(TAG, "init()") + logd("init()") isClickable = false clipChildren = false clipToPadding = false - - addView(backdrop, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) - addView(container, LayoutParams(LayoutParams.MATCH_PARENT, 0)) - container.addView(childrenContainer, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) - - doOnLayout { - Log.d(TAG, "doOnLayout -> hideContainerInstant()") - hideContainerInstant() - } - + super.addView(backdrop, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + super.addView(container, LayoutParams(LayoutParams.MATCH_PARENT, 0)) + container.addView( + childrenContainer, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + ) + doOnLayout { hideContainerInstant() } backdrop.setOnClickListener { - Log.d(TAG, "backdrop onClick (isAnimating=$isAnimating, isPresented=$isPresented)") + logd("backdrop onClick (isAnimating=$isAnimating, isPresented=$isPresented)") if (!isAnimating && isPresented) dismiss() } - dragHelper = ViewDragHelper.create(this, 1f, object : ViewDragHelper.Callback() { override fun tryCaptureView(child: View, pointerId: Int): Boolean { val res = (child === container) && !isAnimating && allowDragNow - Log.d(TAG, "tryCaptureView: allowDragNow=$allowDragNow isAnimating=$isAnimating -> $res") + logd("tryCaptureView: allowDragNow=$allowDragNow isAnimating=$isAnimating -> $res") return res } - override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int = child.left - override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { val minTop = restTop val maxTop = height - val clamped = top.coerceIn(minTop, maxTop) - return clamped + return top.coerceIn(minTop, maxTop) } - override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { updateBackdropForTop(top) val dragging = (dragHelper.viewDragState == ViewDragHelper.STATE_DRAGGING) if (dragging != isDraggingSheet) { - Log.d(TAG, "onViewPositionChanged: isDraggingSheet $isDraggingSheet -> $dragging, top=$top") + logd("onViewPositionChanged: isDraggingSheet $isDraggingSheet -> $dragging, top=$top") } isDraggingSheet = dragging } - override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) { val curTop = releasedChild.top - val shouldDismiss = (yvel > velocityDismissThreshold) || (curTop > restTop + dragRange / 2) - Log.d(TAG, "onViewReleased: top=$curTop, yvel=$yvel, shouldDismiss=$shouldDismiss") + val shouldDismiss = (yvel > velocityDismissThresholdPxPerSec) || (curTop > restTop + dragRange / 2) + logd("onViewReleased: top=$curTop, yvel=$yvel, shouldDismiss=$shouldDismiss") if (shouldDismiss) { settleTo(height) { onDismissAnimationEnd() } } else { @@ -182,73 +213,90 @@ class NativeSheetView @JvmOverloads constructor( isDraggingSheet = false handingBackToChild = false } - override fun getViewVerticalDragRange(child: View): Int = dragRange }) - ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) - keyboardBottomInsetPx = ime.bottom - Log.d(TAG, "Insets IME bottom=$keyboardBottomInsetPx (isPresented=$isPresented)") - if (isPresented) adjustToCurrentContent(animated = true) + logd("Insets IME bottom=${ime.bottom} (isPresented=$isPresented)") + if (isPresented) requestAdjust(animated = true) insets } } + override fun setLayoutParams(params: ViewGroup.LayoutParams?) { + val safe = when (params) { + null -> FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + is FrameLayout.LayoutParams -> params + is ViewGroup.MarginLayoutParams -> FrameLayout.LayoutParams(params) + else -> FrameLayout.LayoutParams(params) + } + super.setLayoutParams(safe) + } + override fun generateDefaultLayoutParams(): FrameLayout.LayoutParams { + return FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + override fun generateLayoutParams(p: ViewGroup.LayoutParams?): FrameLayout.LayoutParams { + return when (p) { + null -> generateDefaultLayoutParams() + is FrameLayout.LayoutParams -> FrameLayout.LayoutParams(p) + is ViewGroup.MarginLayoutParams -> FrameLayout.LayoutParams(p) + else -> FrameLayout.LayoutParams(p) + } + } + override fun checkLayoutParams(p: ViewGroup.LayoutParams?): Boolean = p is FrameLayout.LayoutParams + + fun setEventDispatcher(dispatcher: EventDispatcher) { + logd("setEventDispatcher → $dispatcher") + childrenContainer.eventDispatcher = dispatcher + } + + override fun setId(id: Int) { + super.setId(id) + childrenContainer.id = id + } + + fun addReactChild(child: View, index: Int) { + childrenContainer.addView(child, index) + } + fun reactChildCount(): Int = childrenContainer.childCount + fun reactChildAt(index: Int): View = childrenContainer.getChildAt(index) + fun removeReactChildAt(index: Int) { + childrenContainer.removeViewAt(index) + } + private class ChildTouchDelegate( private val getOffset: () -> Int, private val requestParentIntercept: (Boolean) -> Unit, private val touchSlop: Int ) : View.OnTouchListener { - private var lastY = 0f private var scrollEnabled = true private var passedSlop = false - - fun setScrollEnabled(enabled: Boolean) { - if (enabled != scrollEnabled) { - Log.d("NativeSheetView", "ChildTouchDelegate.setScrollEnabled: $scrollEnabled -> $enabled") - } - scrollEnabled = enabled - } - + fun setScrollEnabled(enabled: Boolean) { scrollEnabled = enabled } override fun onTouch(v: View, ev: MotionEvent): Boolean { when (ev.actionMasked) { MotionEvent.ACTION_DOWN -> { - lastY = ev.y - passedSlop = false + lastY = ev.y; passedSlop = false requestParentIntercept(true) - Log.d("NativeSheetView", "ChildTouchDelegate DOWN: requestDisallowIntercept(true)") return false } - MotionEvent.ACTION_MOVE -> { val dy = ev.y - lastY - if (!passedSlop && kotlin.math.abs(dy) > touchSlop) { - passedSlop = true - Log.d("NativeSheetView", "ChildTouchDelegate MOVE: passedSlop dy=$dy") - } + if (!passedSlop && kotlin.math.abs(dy) > touchSlop) passedSlop = true lastY = ev.y - if (passedSlop && dy > 0f && getOffset() <= 0) { - Log.d("NativeSheetView", "ChildTouchDelegate MOVE: handoff to parent (dy>0, offset<=0) -> requestDisallowIntercept(false)") requestParentIntercept(false) } - - if (!scrollEnabled) { - val eat = passedSlop - if (eat) Log.d("NativeSheetView", "ChildTouchDelegate MOVE: eat (scroll disabled & passedSlop)") - return eat - } - - return false - } - - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - Log.d("NativeSheetView", "ChildTouchDelegate ${if (ev.actionMasked==MotionEvent.ACTION_UP) "UP" else "CANCEL"}: let pass") + if (!scrollEnabled) return passedSlop return false } - + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> return false else -> return false } } @@ -257,14 +305,9 @@ class NativeSheetView @JvmOverloads constructor( private fun attachChildTouchListenerIfNeeded() { val v = scrollableChild ?: return if (childDelegate != null) return - - Log.d(TAG, "attachChildTouchListenerIfNeeded -> attach to ${v.javaClass.simpleName}") childDelegate = ChildTouchDelegate( getOffset = { getVerticalScrollOffset(v) }, - requestParentIntercept = { disallow -> - Log.d(TAG, "Child -> requestDisallowIntercept($disallow)") - v.parent?.requestDisallowInterceptTouchEvent(disallow) - }, + requestParentIntercept = { disallow -> v.parent?.requestDisallowInterceptTouchEvent(disallow) }, touchSlop = ViewConfiguration.get(context).scaledTouchSlop ) v.setOnTouchListener(childDelegate) @@ -272,7 +315,6 @@ class NativeSheetView @JvmOverloads constructor( private fun detachChildTouchListener() { val v = scrollableChild ?: return - Log.d(TAG, "detachChildTouchListener from ${v.javaClass.simpleName}") v.setOnTouchListener(null) childDelegate = null } @@ -280,7 +322,6 @@ class NativeSheetView @JvmOverloads constructor( private fun updateScrollEnabledForContent(currentSheetH: Int) { val maxH = contentMaximumHeight() val enableScroll = currentSheetH >= maxH - Log.d(TAG, "updateScrollEnabledForContent: sheetH=$currentSheetH maxH=$maxH -> scroll=${enableScroll}") childDelegate?.setScrollEnabled(enableScroll) } @@ -288,22 +329,13 @@ class NativeSheetView @JvmOverloads constructor( ensureRefsIfMissing() val hView = headerView val cView = contentView - - var headerH = hView?.measuredHeight ?: 0 - if (headerH == 0) headerH = hView?.height ?: 0 - var contentH = cView?.measuredHeight ?: 0 - if (contentH == 0) contentH = cView?.height ?: 0 - - if (headerH == 0 && hView != null) { - headerH = safeMeasureIfNeeded(hView, width) - } - if (contentH == 0 && cView != null) { - contentH = safeMeasureIfNeeded(cView, width) - } - + var headerH = hView?.measuredHeight ?: hView?.height ?: 0 + var contentH = cView?.measuredHeight ?: cView?.height ?: 0 + if (headerH == 0 && hView != null) headerH = safeMeasureIfNeeded(hView, width) + if (contentH == 0 && cView != null) contentH = safeMeasureIfNeeded(cView, width) headerHeight = headerH contentHeight = contentH - Log.d(TAG, "recomputeHeights: header=$headerHeight content=$contentHeight") + logd("recomputeHeights: header=$headerHeight content=$contentHeight") } private fun currentContentHeight(): Int { @@ -312,7 +344,7 @@ class NativeSheetView @JvmOverloads constructor( is ScrollView, is NestedScrollView -> getScrollViewContentHeight(scroll) else -> contentView?.measuredHeight?.coerceAtLeast(0) ?: 0 } - Log.d(TAG, "currentContentHeight: $h (scrollable=${scroll?.javaClass?.simpleName})") + logd("currentContentHeight: $h (scrollable=${scroll?.javaClass?.simpleName})") return h } @@ -320,10 +352,8 @@ class NativeSheetView @JvmOverloads constructor( val insets = ViewCompat.getRootWindowInsets(this) val sys = insets?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: Insets.NONE val ime = insets?.getInsets(WindowInsetsCompat.Type.ime()) ?: Insets.NONE - val safeTop = sys.top val imeBottom = ime.bottom - val h = height - safeTop - imeBottom return max(h, 0) } @@ -333,7 +363,7 @@ class NativeSheetView @JvmOverloads constructor( fun setBackdropOpacity(value: Float) { backdropMaxAlpha = value.coerceIn(0f, 1f) backdrop.alpha = backdrop.alpha.coerceAtMost(backdropMaxAlpha) - Log.d(TAG, "setBackdropOpacity: $backdropMaxAlpha") + logd("setBackdropOpacity: $backdropMaxAlpha") } fun setCornerRadius(valueDp: Float) { @@ -341,97 +371,90 @@ class NativeSheetView @JvmOverloads constructor( if (px == cornerRadiusPx) return cornerRadiusPx = px container.invalidateOutline() - Log.d(TAG, "setCornerRadiusPx: $cornerRadiusPx") + logd("setCornerRadiusPx: $cornerRadiusPx") } fun present(optionalHeightPx: Int? = null) { if (isAnimating || isPresented) { - Log.d(TAG, "present: skip (isAnimating=$isAnimating isPresented=$isPresented)") + logd("present: skip (isAnimating=$isAnimating isPresented=$isPresented)") return } if (width == 0 || height == 0) { - Log.d(TAG, "present: defer (no size yet)") + logd("present: defer (no size yet)") post { present(optionalHeightPx) } return } - recomputeHeights() if (headerHeight == 0) { - post { - if (isPresented) adjustToCurrentContent(true) - } + post { if (isPresented) requestAdjust(true) } } - val contentH = optionalHeightPx ?: currentContentHeight() val sheetH = calcSheetHeight(contentH) if (sheetH <= 0) { - Log.w(TAG, "present: sheetH<=0, bail") + logw("present: sheetH<=0, bail") return } - cachedHeightPx = sheetH - val frame = frameForHeight(sheetH) - val targetH = frame.height() - + val targetH = frame.height val wSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY) val hSpec = MeasureSpec.makeMeasureSpec(targetH, MeasureSpec.EXACTLY) container.measure(wSpec, hSpec) - updateGeometryForHeight(targetH) placeOffscreenPreservingHeightByLayout() - ensureContentConfiguredOnce() - isAnimating = true isPresented = true - Log.d(TAG, "present: start -> targetH=$targetH restTop=$restTop") - + logd("present: start -> targetH=$targetH restTop=$restTop") animateBackdrop(to = backdropMaxAlpha) settleTo(restTop) { isAnimating = false - Log.d(TAG, "present: finished at top=$restTop") + logd("present: finished at top=$restTop") dispatchEvent("onAppeared", Arguments.createMap()) } } + private fun requestAdjust(animated: Boolean) { + if (adjustPosted) return + adjustPosted = true + post { + adjustPosted = false + adjustToCurrentContent(animated) + } + } + fun adjustToCurrentContent(animated: Boolean) { ensureRefsIfMissing() if (contentView == null) { - Log.d(TAG, "adjustToCurrentContent: no contentView") + logd("adjustToCurrentContent: no contentView") return } if (width == 0 || height == 0) return - recomputeHeights() - val contentH = currentContentHeight() val newSheetH = calcSheetHeight(contentH) if (newSheetH <= 0) return - val newFrame = frameForHeight(newSheetH) - val newH = newFrame.height() + val newH = newFrame.height val sameTop = (container.top == newFrame.top) val sameH = (container.height == newH) - if (sameTop && sameH) { - Log.d(TAG, "adjustToCurrentContent: nothing to change") + logd("adjustToCurrentContent: nothing to change") return } - cachedHeightPx = newSheetH - Log.d(TAG, "adjustToCurrentContent: newH=$newH top=${newFrame.top} animated=$animated isAnimating=$isAnimating") + logd("adjustToCurrentContent: newH=$newH top=${newFrame.top} animated=$animated isAnimating=$isAnimating") applyContainerFrame(newFrame, animated && !isAnimating) } fun dismiss() { if (!isPresented || isAnimating) { - Log.d(TAG, "dismiss: skip (isPresented=$isPresented isAnimating=$isAnimating)") + logd("dismiss: skip (isPresented=$isPresented isAnimating=$isAnimating)") return } isAnimating = true isPresented = false - Log.d(TAG, "dismiss: start") + logd("dismiss: start") animateBackdrop(to = 0f) settleTo(height) { isAnimating = false @@ -453,48 +476,45 @@ class NativeSheetView @JvmOverloads constructor( } } - private fun frameForHeight(sheetHeight: Int): android.graphics.Rect { + private fun frameForHeight(sheetHeight: Int): Frame { val maxH = contentMaximumHeight() val adjustedH = min(maxH, sheetHeight) val top = height - adjustedH - return android.graphics.Rect(0, top, width, top + adjustedH) + return Frame(top, adjustedH) } - private fun applyContainerFrame(newFrame: android.graphics.Rect, animated: Boolean) { - val newH = newFrame.height() + private fun applyContainerFrame(newFrame: Frame, animated: Boolean) { + val newH = newFrame.height val newTop = newFrame.top val oldTop = container.top val oldH = container.height - if (oldTop == newTop && oldH == newH) return - - val wSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY) - val hSpec = MeasureSpec.makeMeasureSpec(newH, MeasureSpec.EXACTLY) - container.measure(wSpec, hSpec) - + val needRemesure = (oldH != newH) + if (needRemesure) { + val wSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY) + val hSpec = MeasureSpec.makeMeasureSpec(newH, MeasureSpec.EXACTLY) + container.measure(wSpec, hSpec) + } freezeBackdrop() computeGeometryForHeight(newH) - if (!animated || isAnimating) { layoutContainer(newTop, newH) updateBackdropForTop(container.top) updateScrollEnabledForContent(newH) unfreezeBackdrop() - Log.d(TAG, "applyContainerFrame: instant -> top=$newTop h=$newH") + logd("applyContainerFrame: instant -> top=$newTop h=$newH") return } - layoutContainer(oldTop, newH) updateBackdropForTop(container.top) - val started = dragHelper.smoothSlideViewTo(container, container.left, newTop) - Log.d(TAG, "applyContainerFrame: animated -> start=$started from=$oldTop to=$newTop h=$newH") + logd("applyContainerFrame: animated -> start=$started from=$oldTop to=$newTop h=$newH") if (started) { postOnAnimation { continueSettling { updateScrollEnabledForContent(newH) unfreezeBackdrop() - Log.d(TAG, "applyContainerFrame: animated finished at top=${container.top}") + logd("applyContainerFrame: animated finished at top=${container.top}") } } } else { @@ -502,7 +522,7 @@ class NativeSheetView @JvmOverloads constructor( updateBackdropForTop(container.top) updateScrollEnabledForContent(newH) unfreezeBackdrop() - Log.d(TAG, "applyContainerFrame: fallback layout -> top=$newTop h=$newH") + logd("applyContainerFrame: fallback layout -> top=$newTop h=$newH") } } @@ -510,7 +530,7 @@ class NativeSheetView @JvmOverloads constructor( restTop = height - targetH dragRange = height - restTop invDragRange = if (dragRange > 0) 1f / dragRange else 0f - Log.d(TAG, "computeGeometryForHeight: targetH=$targetH restTop=$restTop dragRange=$dragRange") + logd("computeGeometryForHeight: targetH=$targetH restTop=$restTop dragRange=$dragRange") } private fun layoutContainer(top: Int, h: Int) { @@ -520,14 +540,14 @@ class NativeSheetView @JvmOverloads constructor( private fun hideContainerInstant() { container.layout(0, height, width, height) backdrop.alpha = 0f - Log.d(TAG, "hideContainerInstant()") + logd("hideContainerInstant()") } private fun placeOffscreenPreservingHeightByLayout() { val h = container.height container.layout(0, height, width, height + h) updateBackdropForTop(container.top) - Log.d(TAG, "placeOffscreenPreservingHeightByLayout: h=$h") + logd("placeOffscreenPreservingHeightByLayout: h=$h") } private fun updateGeometryForHeight(targetH: Int) { @@ -536,19 +556,19 @@ class NativeSheetView @JvmOverloads constructor( invDragRange = if (dragRange > 0) 1f / dragRange else 0f container.layout(0, restTop, width, restTop + targetH) updateBackdropForTop(container.top) - Log.d(TAG, "updateGeometryForHeight: targetH=$targetH restTop=$restTop") + logd("updateGeometryForHeight: targetH=$targetH restTop=$restTop") } private fun freezeBackdrop() { backdropFrozenAlpha = backdrop.alpha backdropFrozen = true - Log.d(TAG, "freezeBackdrop: alpha=$backdropFrozenAlpha") + logd("freezeBackdrop: alpha=$backdropFrozenAlpha") } private fun unfreezeBackdrop() { backdropFrozen = false updateBackdropForTop(container.top) - Log.d(TAG, "unfreezeBackdrop()") + logd("unfreezeBackdrop()") } private fun updateBackdropForTop(curTop: Int) { @@ -564,7 +584,7 @@ class NativeSheetView @JvmOverloads constructor( private fun animateBackdrop(to: Float) { val clamped = to.coerceIn(0f, backdropMaxAlpha) - Log.d(TAG, "animateBackdrop: to=$clamped") + logd("animateBackdrop: to=$clamped") backdrop.animate() .alpha(clamped) .setDuration(240) @@ -574,7 +594,7 @@ class NativeSheetView @JvmOverloads constructor( private fun settleTo(targetTop: Int, end: (() -> Unit)? = null) { val started = dragHelper.smoothSlideViewTo(container, container.left, targetTop) - Log.d(TAG, "settleTo: targetTop=$targetTop started=$started") + logd("settleTo: targetTop=$targetTop started=$started") if (started) { postOnAnimation { continueSettling(end) } } else { @@ -595,7 +615,7 @@ class NativeSheetView @JvmOverloads constructor( } private fun onDismissAnimationEnd() { - Log.d(TAG, "onDismissAnimationEnd -> dispatch onDismissed") + logd("onDismissAnimationEnd -> dispatch onDismissed") dispatchEvent("onDismissed", Arguments.createMap()) } @@ -605,7 +625,6 @@ class NativeSheetView @JvmOverloads constructor( val x = ev.x.toInt() val y = ev.y.toInt() if (!hitInContainer(x, y)) return super.onInterceptTouchEvent(ev) - when (ev.actionMasked) { MotionEvent.ACTION_DOWN -> { downY = ev.y @@ -614,7 +633,7 @@ class NativeSheetView @JvmOverloads constructor( isDraggingSheet = false handingBackToChild = false dragHelper.shouldInterceptTouchEvent(ev) - Log.d(TAG, "onIntercept: DOWN in container") + logd("onIntercept: DOWN in container") return false } MotionEvent.ACTION_MOVE -> { @@ -624,7 +643,7 @@ class NativeSheetView @JvmOverloads constructor( if (passedSlop) { draggingDownGesture = dy > 0 allowDragNow = draggingDownGesture && !canUp && !isAnimating - Log.d(TAG, "onIntercept: MOVE passedSlop dy=$dy draggingDown=$draggingDownGesture canUp=$canUp allowDragNow=$allowDragNow") + logd("onIntercept: MOVE passedSlop dy=$dy draggingDown=$draggingDownGesture canUp=$canUp allowDragNow=$allowDragNow") dragHelper.shouldInterceptTouchEvent(ev) if (allowDragNow) { isDraggingSheet = true @@ -642,11 +661,10 @@ class NativeSheetView @JvmOverloads constructor( isDraggingSheet = false handingBackToChild = false dragHelper.shouldInterceptTouchEvent(ev) - Log.d(TAG, "onIntercept: ${if (ev.actionMasked==MotionEvent.ACTION_UP) "UP" else "CANCEL"}") + logd("onIntercept: ${if (ev.actionMasked==MotionEvent.ACTION_UP) "UP" else "CANCEL"}") return false } } - dragHelper.shouldInterceptTouchEvent(ev) return false } @@ -658,18 +676,17 @@ class NativeSheetView @JvmOverloads constructor( val child = scrollableChild val childCanUp = canChildScrollUp() if (dyNow < -touchSlop && atRest && child != null && childCanUp) { - Log.d(TAG, "onTouchEvent: handOffToChild (dyNow=$dyNow atRest=$atRest childCanUp=$childCanUp)") + logd("onTouchEvent: handOffToChild (dyNow=$dyNow atRest=$atRest childCanUp=$childCanUp)") handOffToChild(child, event) allowDragNow = false isDraggingSheet = false return false } } - dragHelper.processTouchEvent(event) val hit = hitInContainer(event.x.toInt(), event.y.toInt()) if (event.actionMasked == MotionEvent.ACTION_DOWN) { - Log.d(TAG, "onTouchEvent: DOWN hitInContainer=$hit") + logd("onTouchEvent: DOWN hitInContainer=$hit") } return hit } @@ -679,7 +696,7 @@ class NativeSheetView @JvmOverloads constructor( val t = container.top val r = container.right val b = container.bottom - return x in l..r && y in t..b + return x >= l && x < r && y >= t && y < b } private fun getTestId(view: View): String? { @@ -710,21 +727,18 @@ class NativeSheetView @JvmOverloads constructor( if (root == null) return val beforeHeader = headerView != null val beforeContent = contentView != null - scanForTargetsFromView(root, depth = 0, maxDepth = SUBTREE_SCAN_MAX_DEPTH) - if (!beforeHeader && headerView != null) { - Log.d(TAG, "captureRefs: headerView found (${headerView!!.javaClass.simpleName})") + logd("captureRefs: headerView found (${headerView!!.javaClass.simpleName})") } if (!beforeContent && contentView != null) { - Log.d(TAG, "captureRefs: contentView found (${contentView!!.javaClass.simpleName})") + logd("captureRefs: contentView found (${contentView!!.javaClass.simpleName})") } - contentView?.let { v -> if (scrollableChild == null) { scrollableChild = findAndroidScrollableIn(v) if (scrollableChild != null) { - Log.d(TAG, "captureRefs: scrollableChild=${scrollableChild!!.javaClass.simpleName}") + logd("captureRefs: scrollableChild=${scrollableChild!!.javaClass.simpleName}") } } scrollContentChild = when (val s = scrollableChild) { @@ -742,18 +756,17 @@ class NativeSheetView @JvmOverloads constructor( private fun scanForTargetsFromView(view: View, depth: Int, maxDepth: Int) { if (!shouldContinueScanning()) return - val id = getTestId(view) if (id != null) { if (!headerScanExhausted && headerView == null && id == "sheet-header") { headerView = view headerScanExhausted = true - Log.d(TAG, "scan: headerView tagged by testID") + logd("scan: headerView tagged by testID") if (!headerLayoutListenerAttached) { headerView?.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> if (isPresented) { - Log.d(TAG, "header onLayout -> adjustToCurrentContent(true)") - adjustToCurrentContent(true) + logd("header onLayout -> requestAdjust(true)") + requestAdjust(true) } } headerLayoutListenerAttached = true @@ -762,10 +775,9 @@ class NativeSheetView @JvmOverloads constructor( if (!contentScanExhausted && contentView == null && id == "sheet-content") { contentView = view contentScanExhausted = true - Log.d(TAG, "scan: contentView tagged by testID") + logd("scan: contentView tagged by testID") } } - if (!shouldContinueScanning() || depth >= maxDepth) return if (view is ViewGroup) { for (i in 0 until view.childCount) { @@ -778,16 +790,15 @@ class NativeSheetView @JvmOverloads constructor( private fun installTapProtectors() { headerView?.setOnTouchListener { v, e -> if (e.actionMasked == MotionEvent.ACTION_DOWN) { - Log.d(TAG, "tap-protector header: requestDisallowIntercept(true)") + logd("tap-protector header: requestDisallowIntercept(true)") v.parent?.requestDisallowInterceptTouchEvent(true) } false } - if (scrollableChild == null) { contentView?.setOnTouchListener { v, e -> if (e.actionMasked == MotionEvent.ACTION_DOWN) { - Log.d(TAG, "tap-protector content(no-scroll): requestDisallowIntercept(true)") + logd("tap-protector content(no-scroll): requestDisallowIntercept(true)") v.parent?.requestDisallowInterceptTouchEvent(true) } false @@ -799,29 +810,27 @@ class NativeSheetView @JvmOverloads constructor( attachChildTouchListenerIfNeeded() installTapProtectors() if (contentLayoutListenerAdded) return - val contentChild = scrollContentChild if (contentChild != null) { - Log.d(TAG, "ensureContentConfiguredOnce: add listeners to scrollContentChild") + logd("ensureContentConfiguredOnce: add listeners to scrollContentChild") contentChild.addOnLayoutChangeListener(contentLayoutChangeListener) contentGlobalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { if (!isPresented) return@OnGlobalLayoutListener - Log.d(TAG, "content globalLayout -> adjustToCurrentContent(true)") - adjustToCurrentContent(true) + logd("content globalLayout -> requestAdjust(true)") + requestAdjust(true) } contentChild.viewTreeObserver.addOnGlobalLayoutListener(contentGlobalLayoutListener) contentLayoutListenerAdded = true return } - val cv = contentView if (cv != null) { - Log.d(TAG, "ensureContentConfiguredOnce: add listeners to contentView") + logd("ensureContentConfiguredOnce: add listeners to contentView") cv.addOnLayoutChangeListener(contentLayoutChangeListener) contentGlobalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { if (!isPresented) return@OnGlobalLayoutListener - Log.d(TAG, "contentView globalLayout -> adjustToCurrentContent(true)") - adjustToCurrentContent(true) + logd("contentView globalLayout -> requestAdjust(true)") + requestAdjust(true) } cv.viewTreeObserver.addOnGlobalLayoutListener(contentGlobalLayoutListener) contentLayoutListenerAdded = true @@ -830,20 +839,26 @@ class NativeSheetView @JvmOverloads constructor( private val contentLayoutChangeListener = OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> if (!isPresented) return@OnLayoutChangeListener - Log.d(TAG, "content onLayoutChange -> adjustToCurrentContent(true)") - adjustToCurrentContent(animated = true) + logd("content onLayoutChange -> requestAdjust(true)") + requestAdjust(animated = true) } private fun removeContentObservers() { if (!contentLayoutListenerAdded) return - Log.d(TAG, "removeContentObservers()") + logd("removeContentObservers()") scrollContentChild?.let { cc -> try { cc.removeOnLayoutChangeListener(contentLayoutChangeListener) } catch (_: Throwable) {} - try { contentGlobalLayoutListener?.let { l -> cc.viewTreeObserver.removeOnGlobalLayoutListener(l) } } catch (_: Throwable) {} + try { + val vto = cc.viewTreeObserver + if (vto.isAlive) contentGlobalLayoutListener?.let { l -> vto.removeOnGlobalLayoutListener(l) } + } catch (_: Throwable) {} } contentView?.let { cv -> try { cv.removeOnLayoutChangeListener(contentLayoutChangeListener) } catch (_: Throwable) {} - try { contentGlobalLayoutListener?.let { l -> cv.viewTreeObserver.removeOnGlobalLayoutListener(l) } } catch (_: Throwable) {} + try { + val vto = cv.viewTreeObserver + if (vto.isAlive) contentGlobalLayoutListener?.let { l -> vto.removeOnGlobalLayoutListener(l) } + } catch (_: Throwable) {} } contentLayoutListenerAdded = false contentGlobalLayoutListener = null @@ -852,7 +867,7 @@ class NativeSheetView @JvmOverloads constructor( private fun dispatchEvent(eventName: String, payload: WritableMap? = null) { val reactContext = UIManagerHelper.getReactContext(this) ?: return val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return - Log.d(TAG, "dispatchEvent: $eventName") + logd("dispatchEvent: $eventName") dispatcher.dispatchEvent(SimpleEvent(id, eventName, payload)) } @@ -869,15 +884,14 @@ class NativeSheetView @JvmOverloads constructor( } internal fun onChildMounted(child: View) { - Log.d(TAG, "onChildMounted: ${child.javaClass.simpleName}") + logd("onChildMounted: ${child.javaClass.simpleName}") if (shouldContinueScanning()) captureRefsFromViewIfNeeded(child) } internal fun requestFullRescan() { if (isRescanning) return isRescanning = true - Log.d(TAG, "requestFullRescan()") - + logd("requestFullRescan()") detachChildTouchListener() removeContentObservers() scrollableChild = null @@ -887,20 +901,19 @@ class NativeSheetView @JvmOverloads constructor( headerScanExhausted = false contentScanExhausted = false headerLayoutListenerAttached = false - post { captureRefsFromViewIfNeeded(childrenContainer) isRescanning = false if (isPresented && contentView != null) { - Log.d(TAG, "requestFullRescan -> adjustToCurrentContent(true)") - adjustToCurrentContent(animated = true) + logd("requestFullRescan -> requestAdjust(true)") + requestAdjust(animated = true) } } } override fun onAttachedToWindow() { super.onAttachedToWindow() - Log.d(TAG, "onAttachedToWindow") + logd("onAttachedToWindow") viewTreeObserver.addOnGlobalLayoutListener(layoutListener) if (!isPresented && !isAnimating) { post { present(null) } @@ -909,7 +922,7 @@ class NativeSheetView @JvmOverloads constructor( override fun onDetachedFromWindow() { super.onDetachedFromWindow() - Log.d(TAG, "onDetachedFromWindow") + logd("onDetachedFromWindow") viewTreeObserver.removeOnGlobalLayoutListener(layoutListener) detachChildTouchListener() removeContentObservers() @@ -921,7 +934,6 @@ class NativeSheetView @JvmOverloads constructor( contentScanExhausted = false headerLayoutListenerAttached = false cachedHeightPx = 0 - keyboardBottomInsetPx = 0 isAnimating = false isPresented = false hideContainerInstant() @@ -930,8 +942,7 @@ class NativeSheetView @JvmOverloads constructor( private fun canChildScrollUp(): Boolean { val v = scrollableChild - val res = v?.canScrollVertically(-1) == true - return res + return v?.canScrollVertically(-1) == true } private fun getVerticalScrollOffset(v: View?): Int { @@ -962,25 +973,23 @@ class NativeSheetView @JvmOverloads constructor( private fun handOffToChild(child: View, srcEvent: MotionEvent) { handingBackToChild = true - Log.d(TAG, "handOffToChild -> ${child.javaClass.simpleName}") + logd("handOffToChild -> ${child.javaClass.simpleName}") child.parent?.requestDisallowInterceptTouchEvent(true) - - val cancel = MotionEvent.obtain(srcEvent.downTime, srcEvent.eventTime, - MotionEvent.ACTION_CANCEL, srcEvent.x, srcEvent.y, srcEvent.metaState) + val cancel = MotionEvent.obtain( + srcEvent.downTime, srcEvent.eventTime, + MotionEvent.ACTION_CANCEL, srcEvent.x, srcEvent.y, srcEvent.metaState + ) super.onTouchEvent(cancel) dragHelper.cancel() cancel.recycle() - val locChild = IntArray(2) child.getLocationOnScreen(locChild) val childX = srcEvent.rawX - locChild[0] val childY = srcEvent.rawY - locChild[1] val now = SystemClock.uptimeMillis() - val down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, childX, childY, srcEvent.metaState) child.dispatchTouchEvent(down) down.recycle() - val move = MotionEvent.obtain(now, now, MotionEvent.ACTION_MOVE, childX, childY, srcEvent.metaState) child.dispatchTouchEvent(move) move.recycle() diff --git a/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt b/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt index 943fc1d..ae7766d 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt @@ -1,9 +1,9 @@ package com.nativesheet import android.view.View -import android.view.ViewGroup import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.uimanager.annotations.ReactProp @@ -39,31 +39,31 @@ class NativeSheetViewManager : override fun onAfterUpdateTransaction(view: NativeSheetView) { super.onAfterUpdateTransaction(view) - view.requestFullRescan() } override fun addView(parent: NativeSheetView, child: View, index: Int) { - val container: ViewGroup = parent.childrenContainer - val safeIndex = index.coerceIn(0, container.childCount) - container.addView(child, safeIndex) - parent.onChildMounted(child) + val safeIndex = index.coerceIn(0, parent.reactChildCount()) + parent.addReactChild(child, safeIndex) } - override fun getChildAt(parent: NativeSheetView, index: Int): View { - return parent.childrenContainer.getChildAt(index) - } + override fun getChildAt(parent: NativeSheetView, index: Int): View = + parent.reactChildAt(index) - override fun getChildCount(parent: NativeSheetView): Int { - return parent.childrenContainer.childCount - } + override fun getChildCount(parent: NativeSheetView): Int = + parent.reactChildCount() override fun removeViewAt(parent: NativeSheetView, index: Int) { - parent.childrenContainer.removeViewAt(index) - parent.requestFullRescan() + parent.removeReactChildAt(index) } override fun needsCustomLayoutForChildren(): Boolean = false + override fun addEventEmitters(reactContext: ThemedReactContext, view: NativeSheetView) { + UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)?.let { dispatcher -> + view.setEventDispatcher(dispatcher) + } + } + companion object { const val NAME = "NativeSheetView" } diff --git a/android/src/main/java/com/nativesheet/RootChildren.kt b/android/src/main/java/com/nativesheet/RootChildren.kt new file mode 100644 index 0000000..16d4496 --- /dev/null +++ b/android/src/main/java/com/nativesheet/RootChildren.kt @@ -0,0 +1,80 @@ +package com.nativesheet + +import android.content.Context +import android.view.MotionEvent +import android.view.View +import android.util.Log +import com.facebook.react.config.ReactFeatureFlags +import com.facebook.react.uimanager.JSPointerDispatcher +import com.facebook.react.uimanager.JSTouchDispatcher +import com.facebook.react.uimanager.RootView +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.events.EventDispatcher +import com.facebook.react.views.view.ReactViewGroup + +class RootChildren(context: Context) : ReactViewGroup(context), RootView { + + private val touch = JSTouchDispatcher(this) + private val pointer = if (ReactFeatureFlags.dispatchPointerEvents) JSPointerDispatcher(this) else null + + var eventDispatcher: EventDispatcher? = null + + private val reactContext: ThemedReactContext + get() = context as ThemedReactContext + + init { + isClickable = true + isFocusable = true + isFocusableInTouchMode = true + } + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + Log.d("RootChildren", "dispatchTouchEvent ${MotionEvent.actionToString(ev.actionMasked)}") + return super.dispatchTouchEvent(ev) + } + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + Log.d("RootChildren", "onInterceptTouchEvent ${MotionEvent.actionToString(event.actionMasked)}") + eventDispatcher?.let { ed -> + touch.handleTouchEvent(event, ed, reactContext) + pointer?.handleMotionEvent(event, ed, /*isIntercept=*/true) + } + return super.onInterceptTouchEvent(event) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + Log.d("RootChildren", "onTouchEvent ${MotionEvent.actionToString(event.actionMasked)}") + eventDispatcher?.let { ed -> + touch.handleTouchEvent(event, ed, reactContext) + pointer?.handleMotionEvent(event, ed, /*isIntercept=*/false) + } + super.onTouchEvent(event) + return true + } + + override fun onInterceptHoverEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { pointer?.handleMotionEvent(event, it, true) } + return super.onInterceptHoverEvent(event) + } + + override fun onHoverEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { pointer?.handleMotionEvent(event, it, false) } + return super.onHoverEvent(event) + } + + override fun onChildStartedNativeGesture(childView: View?, ev: MotionEvent) { + eventDispatcher?.let { ed -> + touch.onChildStartedNativeGesture(ev, ed) + pointer?.onChildStartedNativeGesture(childView, ev, ed) + } + } + + override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) { + eventDispatcher?.let { touch.onChildEndedNativeGesture(ev, it) } + pointer?.onChildEndedNativeGesture() + } + + override fun handleException(t: Throwable) { + reactContext.reactApplicationContext.handleException(RuntimeException(t)) + } +} From 1e71e2675309336b9e046272aff50b09af6ebf28 Mon Sep 17 00:00:00 2001 From: bogoslavskiy Date: Sat, 11 Oct 2025 22:27:47 +0300 Subject: [PATCH 06/12] fix(mobile): revert App.tsx --- example/src/App.tsx | 107 +++++++++++++++----------------------------- 1 file changed, 35 insertions(+), 72 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index 666ca74..a483bfa 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,7 +1,6 @@ import { memo, useRef, useState, type PropsWithChildren } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ScreenStack, ScreenStackItem } from 'react-native-screens'; -import { SafeAreaProvider } from 'react-native-safe-area-context'; import { Commands, NativeSheetView } from '@sigmela/native-sheet'; import { ExampleSheet } from './ExampleSheet'; import { @@ -14,52 +13,17 @@ import { const HomeScreen = () => { const { sheets, setSheets } = useSheets(); - const [isVisible, setIsVisible] = useState(false); - const ref = useRef>(null); - return ( - Home - { - // console.time('SCREENS'); setSheets([...sheets, createSheet(ExampleSheet)]); }} > Open Screens Modal - { - // console.time('LOCAL'); - // Commands.present(ref.current!); - setIsVisible(true); - }} - > - Open LOCAL Modal - - {isVisible && ( - - { - setIsVisible(false); - }} - onAppeared={() => { - console.log('onAppeared'); - }} - // onDismissed={onDismissed}d - > - {} }}> - - - - - )} ); }; @@ -67,33 +31,36 @@ const HomeScreen = () => { export default function App() { const [sheets, setSheets] = useState([]); + const dismiss = (id: number) => { + setSheets((sheetsState) => sheetsState.filter((s) => s.id !== id)); + }; + return ( - - - - {}} - style={StyleSheet.absoluteFill} - headerConfig={{ title: 'Home' }} + + + + + + {sheets.map((sheet) => ( + { + setSheets((sheetsState) => + sheetsState.filter((s) => s.id !== sheet.id) + ); + }} > - - - {sheets.map((sheet) => ( - { - setSheets((sheets) => sheets.filter((s) => s.id !== sheet.id)); - }} - > - - - ))} - - - + + + ))} + + ); } @@ -114,26 +81,23 @@ const StackScreenSheetItem = memo((props: StackScreenSheetItemProps) => { headerConfig={headerConfig} style={StyleSheet.absoluteFill} key={`${screenId}-sheet-item`} + contentStyle={styles.content} screenId={screenId} stackAnimation="none" - gestureEnabled={false} onDismissed={() => { onDismissed(); }} > { - onDismissed(); - console.log('onDismissed'); - }} - onAppeared={() => { - console.log('onAppeared'); - }} + style={styles.flex1} + onDismissed={onDismissed} + cornerRadius={18} > Commands.dismiss(ref.current!) }} + value={{ + dismiss: () => ref.current && Commands.dismiss(ref.current), + }} > {children} @@ -147,7 +111,6 @@ const styles = StyleSheet.create({ flex: 1, }, content: { - flex: 1, backgroundColor: 'transparent', }, From d3c4b81c013848061e841434ee4b98a20a5038b3 Mon Sep 17 00:00:00 2001 From: bogoslavskiy Date: Sat, 11 Oct 2025 22:28:37 +0300 Subject: [PATCH 07/12] fix(mobile): add SafeAreaProvider --- example/src/App.tsx | 51 ++++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index a483bfa..174b487 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -2,6 +2,7 @@ import { memo, useRef, useState, type PropsWithChildren } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ScreenStack, ScreenStackItem } from 'react-native-screens'; import { Commands, NativeSheetView } from '@sigmela/native-sheet'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; import { ExampleSheet } from './ExampleSheet'; import { SheetsContext, @@ -36,31 +37,33 @@ export default function App() { }; return ( - - - - - - {sheets.map((sheet) => ( - { - setSheets((sheetsState) => - sheetsState.filter((s) => s.id !== sheet.id) - ); - }} + + + + - - - ))} - - + + + {sheets.map((sheet) => ( + { + setSheets((sheetsState) => + sheetsState.filter((s) => s.id !== sheet.id) + ); + }} + > + + + ))} + + + ); } From 189d8218479276edc2b0da8a756c2d85a1715e5d Mon Sep 17 00:00:00 2001 From: bogoslavskiy Date: Sun, 12 Oct 2025 02:23:26 +0300 Subject: [PATCH 08/12] feat(android): add fullscreenTopInset --- .../java/com/nativesheet/NativeSheetView.kt | 174 +++++++++++++++--- .../com/nativesheet/NativeSheetViewManager.kt | 5 + example/src/App.tsx | 1 + src/NativeSheetViewNativeComponent.ts | 1 + 4 files changed, 157 insertions(+), 24 deletions(-) diff --git a/android/src/main/java/com/nativesheet/NativeSheetView.kt b/android/src/main/java/com/nativesheet/NativeSheetView.kt index b2a5bb3..0908bc6 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetView.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetView.kt @@ -38,7 +38,7 @@ class NativeSheetView @JvmOverloads constructor( ) : FrameLayout(context, attrs, defStyleAttr) { private val TAG = "NativeSheetView" - private var loggingEnabled = false + private var loggingEnabled = true fun setLoggingEnabled(enabled: Boolean) { loggingEnabled = enabled } private inline fun logd(msg: String) { if (loggingEnabled) Log.d(TAG, msg) } private inline fun logw(msg: String) { if (loggingEnabled) Log.w(TAG, msg) } @@ -156,8 +156,24 @@ class NativeSheetView @JvmOverloads constructor( private var adjustPosted = false + + private var suppressAdjust = false + + private var fullscreenTopInsetPx: Int = 0 + private var pendingFullscreenTopInsetDp: Float? = null + fun setFullscreenTopInset(valueDp: Float) { + pendingFullscreenTopInsetDp = valueDp + logd("setFullscreenTopInset(dp) received: $valueDp (will apply onAttachedToWindow)") + } + + private fun fullscreenTargetHeight(): Int { + val insets = ViewCompat.getRootWindowInsets(this) + val ime = insets?.getInsets(WindowInsetsCompat.Type.ime()) ?: Insets.NONE + return max(height - fullscreenTopInsetPx - ime.bottom, 0) + } + private val layoutListener = ViewTreeObserver.OnGlobalLayoutListener { - if (isPresented) { + if (isPresented && !suppressAdjust) { logd("GlobalLayout -> requestAdjust(animated=true)") requestAdjust(animated = true) } @@ -205,8 +221,21 @@ class NativeSheetView @JvmOverloads constructor( val curTop = releasedChild.top val shouldDismiss = (yvel > velocityDismissThresholdPxPerSec) || (curTop > restTop + dragRange / 2) logd("onViewReleased: top=$curTop, yvel=$yvel, shouldDismiss=$shouldDismiss") + if (shouldDismiss) { - settleTo(height) { onDismissAnimationEnd() } + suppressAdjust = true + adjustPosted = false + allowDragNow = false + isAnimating = true + isPresented = false + animateBackdrop(to = 0f) + + settleTo(height) { + hideContainerInstant() + isAnimating = false + suppressAdjust = false + onDismissAnimationEnd() + } } else { settleTo(restTop) { } } @@ -218,7 +247,7 @@ class NativeSheetView @JvmOverloads constructor( ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) logd("Insets IME bottom=${ime.bottom} (isPresented=$isPresented)") - if (isPresented) requestAdjust(animated = true) + if (isPresented && !suppressAdjust) requestAdjust(animated = true) insets } } @@ -350,12 +379,17 @@ class NativeSheetView @JvmOverloads constructor( private fun contentMaximumHeight(): Int { val insets = ViewCompat.getRootWindowInsets(this) - val sys = insets?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: Insets.NONE val ime = insets?.getInsets(WindowInsetsCompat.Type.ime()) ?: Insets.NONE - val safeTop = sys.top - val imeBottom = ime.bottom - val h = height - safeTop - imeBottom - return max(h, 0) + + return if (fullscreenTopInsetPx > 0) { + max(height - fullscreenTopInsetPx - ime.bottom, 0) + } else { + val sys = insets?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: Insets.NONE + val safeTop = sys.top + val imeBottom = ime.bottom + val h = height - safeTop - imeBottom + max(h, 0) + } } private fun calcSheetHeight(contentH: Int): Int = headerHeight + contentH @@ -384,9 +418,38 @@ class NativeSheetView @JvmOverloads constructor( post { present(optionalHeightPx) } return } + + if (fullscreenTopInsetPx > 0) { + val targetH = fullscreenTargetHeight() + cachedHeightPx = targetH + val wSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY) + val hSpec = MeasureSpec.makeMeasureSpec(targetH, MeasureSpec.EXACTLY) + container.measure(wSpec, hSpec) + + restTop = fullscreenTopInsetPx + dragRange = height - restTop + invDragRange = if (dragRange > 0) 1f / dragRange else 0f + + layoutContainer(restTop, targetH) + placeOffscreenPreservingHeightByLayout() + ensureRefsIfMissing() + attachChildTouchListenerIfNeeded() + ensureContentConfiguredOnce() + + isAnimating = true + isPresented = true + animateBackdrop(to = backdropMaxAlpha) + settleTo(restTop) { + isAnimating = false + dispatchEvent("onAppeared", Arguments.createMap()) + } + return + } + + recomputeHeights() if (headerHeight == 0) { - post { if (isPresented) requestAdjust(true) } + post { if (isPresented && !suppressAdjust) requestAdjust(true) } } val contentH = optionalHeightPx ?: currentContentHeight() val sheetH = calcSheetHeight(contentH) @@ -415,21 +478,43 @@ class NativeSheetView @JvmOverloads constructor( } private fun requestAdjust(animated: Boolean) { + if (suppressAdjust) return if (adjustPosted) return adjustPosted = true post { adjustPosted = false + if (!isPresented || suppressAdjust) return@post adjustToCurrentContent(animated) } } fun adjustToCurrentContent(animated: Boolean) { + if (!isPresented || suppressAdjust) { + logd("adjust: skip (presented=$isPresented suppress=$suppressAdjust)") + return + } + if (width == 0 || height == 0) return + + if (fullscreenTopInsetPx > 0) { + val targetH = contentMaximumHeight() + val newFrame = Frame(top = fullscreenTopInsetPx, height = targetH) + val sameTop = (container.top == newFrame.top) + val sameH = (container.height == newFrame.height) + if (sameTop && sameH) { + logd("adjustToCurrentContent(fullscreen): nothing to change") + return + } + cachedHeightPx = targetH + logd("adjustToCurrentContent(fullscreen): newH=${newFrame.height} top=${newFrame.top} animated=$animated isAnimating=$isAnimating") + applyContainerFrame(newFrame, animated && !isAnimating) + return + } + ensureRefsIfMissing() if (contentView == null) { logd("adjustToCurrentContent: no contentView") return } - if (width == 0 || height == 0) return recomputeHeights() val contentH = currentContentHeight() val newSheetH = calcSheetHeight(contentH) @@ -452,12 +537,17 @@ class NativeSheetView @JvmOverloads constructor( logd("dismiss: skip (isPresented=$isPresented isAnimating=$isAnimating)") return } + suppressAdjust = true + adjustPosted = false + allowDragNow = false isAnimating = true isPresented = false logd("dismiss: start") animateBackdrop(to = 0f) settleTo(height) { + hideContainerInstant() isAnimating = false + suppressAdjust = false onDismissAnimationEnd() } } @@ -477,15 +567,21 @@ class NativeSheetView @JvmOverloads constructor( } private fun frameForHeight(sheetHeight: Int): Frame { + if (fullscreenTopInsetPx > 0) { + val h = fullscreenTargetHeight() + return Frame(top = fullscreenTopInsetPx, height = h) + } val maxH = contentMaximumHeight() val adjustedH = min(maxH, sheetHeight) val top = height - adjustedH return Frame(top, adjustedH) } - private fun applyContainerFrame(newFrame: Frame, animated: Boolean) { - val newH = newFrame.height - val newTop = newFrame.top + val normalized = if (fullscreenTopInsetPx > 0) { + Frame(top = fullscreenTopInsetPx, height = fullscreenTargetHeight()) + } else newFrame + val newH = normalized.height + val newTop = normalized.top val oldTop = container.top val oldH = container.height if (oldTop == newTop && oldH == newH) return @@ -551,6 +647,17 @@ class NativeSheetView @JvmOverloads constructor( } private fun updateGeometryForHeight(targetH: Int) { + if (fullscreenTopInsetPx > 0) { + restTop = fullscreenTopInsetPx + dragRange = height - restTop + invDragRange = if (dragRange > 0) 1f / dragRange else 0f + val h = fullscreenTargetHeight() + container.layout(0, restTop, width, restTop + h) + updateBackdropForTop(container.top) + logd("updateGeometryForHeight(fullscreen): h=$h restTop=$restTop") + return + } + restTop = height - targetH dragRange = height - restTop invDragRange = if (dragRange > 0) 1f / dragRange else 0f @@ -572,6 +679,10 @@ class NativeSheetView @JvmOverloads constructor( } private fun updateBackdropForTop(curTop: Int) { + if (!isPresented) { + backdrop.alpha = 0f + return + } if (backdropFrozen) { backdrop.alpha = backdropFrozenAlpha return @@ -764,7 +875,7 @@ class NativeSheetView @JvmOverloads constructor( logd("scan: headerView tagged by testID") if (!headerLayoutListenerAttached) { headerView?.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> - if (isPresented) { + if (isPresented && fullscreenTopInsetPx == 0 && !suppressAdjust) { logd("header onLayout -> requestAdjust(true)") requestAdjust(true) } @@ -816,8 +927,10 @@ class NativeSheetView @JvmOverloads constructor( contentChild.addOnLayoutChangeListener(contentLayoutChangeListener) contentGlobalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { if (!isPresented) return@OnGlobalLayoutListener - logd("content globalLayout -> requestAdjust(true)") - requestAdjust(true) + if (fullscreenTopInsetPx == 0 && !suppressAdjust) { + logd("content globalLayout -> requestAdjust(true)") + requestAdjust(true) + } } contentChild.viewTreeObserver.addOnGlobalLayoutListener(contentGlobalLayoutListener) contentLayoutListenerAdded = true @@ -829,8 +942,10 @@ class NativeSheetView @JvmOverloads constructor( cv.addOnLayoutChangeListener(contentLayoutChangeListener) contentGlobalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { if (!isPresented) return@OnGlobalLayoutListener - logd("contentView globalLayout -> requestAdjust(true)") - requestAdjust(true) + if (fullscreenTopInsetPx == 0 && !suppressAdjust) { + logd("contentView globalLayout -> requestAdjust(true)") + requestAdjust(true) + } } cv.viewTreeObserver.addOnGlobalLayoutListener(contentGlobalLayoutListener) contentLayoutListenerAdded = true @@ -839,8 +954,10 @@ class NativeSheetView @JvmOverloads constructor( private val contentLayoutChangeListener = OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> if (!isPresented) return@OnLayoutChangeListener - logd("content onLayoutChange -> requestAdjust(true)") - requestAdjust(animated = true) + if (fullscreenTopInsetPx == 0 && !suppressAdjust) { + logd("content onLayoutChange -> requestAdjust(true)") + requestAdjust(animated = true) + } } private fun removeContentObservers() { @@ -904,7 +1021,7 @@ class NativeSheetView @JvmOverloads constructor( post { captureRefsFromViewIfNeeded(childrenContainer) isRescanning = false - if (isPresented && contentView != null) { + if (isPresented && contentView != null && !suppressAdjust) { logd("requestFullRescan -> requestAdjust(true)") requestAdjust(animated = true) } @@ -915,6 +1032,15 @@ class NativeSheetView @JvmOverloads constructor( super.onAttachedToWindow() logd("onAttachedToWindow") viewTreeObserver.addOnGlobalLayoutListener(layoutListener) + + if (fullscreenTopInsetPx == 0) { + val dp = pendingFullscreenTopInsetDp + if (dp != null && dp > 0f) { + fullscreenTopInsetPx = (dp * resources.displayMetrics.density).toInt() + logd("fullscreenTopInset applied on attach: ${fullscreenTopInsetPx}px") + } + } + if (!isPresented && !isAnimating) { post { present(null) } } @@ -941,8 +1067,8 @@ class NativeSheetView @JvmOverloads constructor( } private fun canChildScrollUp(): Boolean { - val v = scrollableChild - return v?.canScrollVertically(-1) == true + val v = scrollableChild ?: return false + return getVerticalScrollOffset(v) > 0 } private fun getVerticalScrollOffset(v: View?): Int { diff --git a/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt b/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt index ae7766d..233173b 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt @@ -33,6 +33,11 @@ class NativeSheetViewManager : view.setCornerRadius(value) } + @ReactProp(name = "fullscreenTopInset", defaultFloat = 0f) + override fun setFullscreenTopInset(view: NativeSheetView, value: Float) { + view.setFullscreenTopInset(value) + } + override fun dismiss(view: NativeSheetView) { view.dismiss() } diff --git a/example/src/App.tsx b/example/src/App.tsx index 174b487..4efe82b 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -96,6 +96,7 @@ const StackScreenSheetItem = memo((props: StackScreenSheetItemProps) => { style={styles.flex1} onDismissed={onDismissed} cornerRadius={18} + fullscreenTopInset={120} > >; onDismissed?: CodegenTypes.DirectEventHandler>; } From 1a5afa7e24776f0b4323782e44512ab5662b6649 Mon Sep 17 00:00:00 2001 From: bogoslavskiy Date: Sun, 12 Oct 2025 03:24:50 +0300 Subject: [PATCH 09/12] fix(android): fullscreen bottom insets --- .../java/com/nativesheet/NativeSheetView.kt | 55 +++++++++++++++++-- example/src/App.tsx | 1 - example/src/ExampleSheet.tsx | 4 +- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/android/src/main/java/com/nativesheet/NativeSheetView.kt b/android/src/main/java/com/nativesheet/NativeSheetView.kt index 0908bc6..274faee 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetView.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetView.kt @@ -156,7 +156,6 @@ class NativeSheetView @JvmOverloads constructor( private var adjustPosted = false - private var suppressAdjust = false private var fullscreenTopInsetPx: Int = 0 @@ -166,6 +165,36 @@ class NativeSheetView @JvmOverloads constructor( logd("setFullscreenTopInset(dp) received: $valueDp (will apply onAttachedToWindow)") } + private var scrollBottomInsetApplied = false + + private fun applyBottomInsetToScrollableIfNeeded() { + if (fullscreenTopInsetPx <= 0 || scrollBottomInsetApplied) return + val s = scrollableChild ?: return + + val bottomPad = fullscreenTopInsetPx + + when (s) { + is ScrollView -> { + s.setPadding(s.paddingLeft, s.paddingTop, s.paddingRight, s.paddingBottom + bottomPad) + s.clipToPadding = false + scrollBottomInsetApplied = true + logd("applyBottomInsetToScrollable: +$bottomPad to ScrollView") + } + is NestedScrollView -> { + s.setPadding(s.paddingLeft, s.paddingTop, s.paddingRight, s.paddingBottom + bottomPad) + s.clipToPadding = false + scrollBottomInsetApplied = true + logd("applyBottomInsetToScrollable: +$bottomPad to NestedScrollView") + } + is RecyclerView -> { + s.setPadding(s.paddingLeft, s.paddingTop, s.paddingRight, s.paddingBottom + bottomPad) + s.clipToPadding = false + scrollBottomInsetApplied = true + logd("applyBottomInsetToScrollable: +$bottomPad to RecyclerView") + } + } + } + private fun fullscreenTargetHeight(): Int { val insets = ViewCompat.getRootWindowInsets(this) val ime = insets?.getInsets(WindowInsetsCompat.Type.ime()) ?: Insets.NONE @@ -248,6 +277,9 @@ class NativeSheetView @JvmOverloads constructor( val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) logd("Insets IME bottom=${ime.bottom} (isPresented=$isPresented)") if (isPresented && !suppressAdjust) requestAdjust(animated = true) + if (fullscreenTopInsetPx > 0) { + applyBottomInsetToScrollableIfNeeded() + } insets } } @@ -429,13 +461,14 @@ class NativeSheetView @JvmOverloads constructor( restTop = fullscreenTopInsetPx dragRange = height - restTop invDragRange = if (dragRange > 0) 1f / dragRange else 0f - + layoutContainer(restTop, targetH) placeOffscreenPreservingHeightByLayout() ensureRefsIfMissing() attachChildTouchListenerIfNeeded() ensureContentConfiguredOnce() - + applyBottomInsetToScrollableIfNeeded() + isAnimating = true isPresented = true animateBackdrop(to = backdropMaxAlpha) @@ -445,7 +478,6 @@ class NativeSheetView @JvmOverloads constructor( } return } - recomputeHeights() if (headerHeight == 0) { @@ -502,11 +534,13 @@ class NativeSheetView @JvmOverloads constructor( val sameH = (container.height == newFrame.height) if (sameTop && sameH) { logd("adjustToCurrentContent(fullscreen): nothing to change") + applyBottomInsetToScrollableIfNeeded() return } cachedHeightPx = targetH logd("adjustToCurrentContent(fullscreen): newH=${newFrame.height} top=${newFrame.top} animated=$animated isAnimating=$isAnimating") applyContainerFrame(newFrame, animated && !isAnimating) + applyBottomInsetToScrollableIfNeeded() return } @@ -657,7 +691,7 @@ class NativeSheetView @JvmOverloads constructor( logd("updateGeometryForHeight(fullscreen): h=$h restTop=$restTop") return } - + restTop = height - targetH dragRange = height - restTop invDragRange = if (dragRange > 0) 1f / dragRange else 0f @@ -920,7 +954,10 @@ class NativeSheetView @JvmOverloads constructor( private fun ensureContentConfiguredOnce() { attachChildTouchListenerIfNeeded() installTapProtectors() - if (contentLayoutListenerAdded) return + if (contentLayoutListenerAdded) { + applyBottomInsetToScrollableIfNeeded() + return + } val contentChild = scrollContentChild if (contentChild != null) { logd("ensureContentConfiguredOnce: add listeners to scrollContentChild") @@ -934,6 +971,7 @@ class NativeSheetView @JvmOverloads constructor( } contentChild.viewTreeObserver.addOnGlobalLayoutListener(contentGlobalLayoutListener) contentLayoutListenerAdded = true + applyBottomInsetToScrollableIfNeeded() return } val cv = contentView @@ -949,6 +987,7 @@ class NativeSheetView @JvmOverloads constructor( } cv.viewTreeObserver.addOnGlobalLayoutListener(contentGlobalLayoutListener) contentLayoutListenerAdded = true + applyBottomInsetToScrollableIfNeeded() } } @@ -1018,8 +1057,11 @@ class NativeSheetView @JvmOverloads constructor( headerScanExhausted = false contentScanExhausted = false headerLayoutListenerAttached = false + scrollBottomInsetApplied = false post { captureRefsFromViewIfNeeded(childrenContainer) + + applyBottomInsetToScrollableIfNeeded() isRescanning = false if (isPresented && contentView != null && !suppressAdjust) { logd("requestFullRescan -> requestAdjust(true)") @@ -1062,6 +1104,7 @@ class NativeSheetView @JvmOverloads constructor( cachedHeightPx = 0 isAnimating = false isPresented = false + scrollBottomInsetApplied = false hideContainerInstant() backdrop.alpha = 0f } diff --git a/example/src/App.tsx b/example/src/App.tsx index 4efe82b..174b487 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -96,7 +96,6 @@ const StackScreenSheetItem = memo((props: StackScreenSheetItemProps) => { style={styles.flex1} onDismissed={onDismissed} cornerRadius={18} - fullscreenTopInset={120} > { const insets = useSafeAreaInsets(); return ( - + <> {Platform.OS === 'android' && ( { ))} - + ); }; From 766ccf3b39f2bbca9d128e3028679543dc4c09cc Mon Sep 17 00:00:00 2001 From: bogoslavskiy Date: Sun, 12 Oct 2025 03:40:11 +0300 Subject: [PATCH 10/12] fix(android): bottom insets --- .../java/com/nativesheet/NativeSheetView.kt | 76 +++++++++++++------ example/src/App.tsx | 1 + 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/android/src/main/java/com/nativesheet/NativeSheetView.kt b/android/src/main/java/com/nativesheet/NativeSheetView.kt index 274faee..f69b16d 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetView.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetView.kt @@ -38,8 +38,8 @@ class NativeSheetView @JvmOverloads constructor( ) : FrameLayout(context, attrs, defStyleAttr) { private val TAG = "NativeSheetView" - private var loggingEnabled = true - fun setLoggingEnabled(enabled: Boolean) { loggingEnabled = enabled } + private var loggingEnabled = false + private inline fun logd(msg: String) { if (loggingEnabled) Log.d(TAG, msg) } private inline fun logw(msg: String) { if (loggingEnabled) Log.w(TAG, msg) } @@ -166,33 +166,48 @@ class NativeSheetView @JvmOverloads constructor( } private var scrollBottomInsetApplied = false - + private var scrollBaseBottomPadding = 0 + private fun bottomPadForScrollable(): Int { + val insets = ViewCompat.getRootWindowInsets(this) + val sys = insets?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: Insets.NONE + return if (fullscreenTopInsetPx > 0) fullscreenTopInsetPx else sys.bottom + } private fun applyBottomInsetToScrollableIfNeeded() { - if (fullscreenTopInsetPx <= 0 || scrollBottomInsetApplied) return val s = scrollableChild ?: return + val bottomPad = bottomPadForScrollable() + if (bottomPad <= 0) return + + if (!scrollBottomInsetApplied) { + scrollBaseBottomPadding = s.paddingBottom + } - val bottomPad = fullscreenTopInsetPx + val desired = scrollBaseBottomPadding + bottomPad when (s) { is ScrollView -> { - s.setPadding(s.paddingLeft, s.paddingTop, s.paddingRight, s.paddingBottom + bottomPad) - s.clipToPadding = false - scrollBottomInsetApplied = true - logd("applyBottomInsetToScrollable: +$bottomPad to ScrollView") + if (s.paddingBottom != desired) { + s.setPadding(s.paddingLeft, s.paddingTop, s.paddingRight, desired) + s.clipToPadding = false + logd("applyBottomInsetToScrollable(ScrollView): base=${scrollBaseBottomPadding} inset=$bottomPad -> $desired") + } } is NestedScrollView -> { - s.setPadding(s.paddingLeft, s.paddingTop, s.paddingRight, s.paddingBottom + bottomPad) - s.clipToPadding = false - scrollBottomInsetApplied = true - logd("applyBottomInsetToScrollable: +$bottomPad to NestedScrollView") + if (s.paddingBottom != desired) { + s.setPadding(s.paddingLeft, s.paddingTop, s.paddingRight, desired) + s.clipToPadding = false + logd("applyBottomInsetToScrollable(NestedScrollView): base=${scrollBaseBottomPadding} inset=$bottomPad -> $desired") + } } is RecyclerView -> { - s.setPadding(s.paddingLeft, s.paddingTop, s.paddingRight, s.paddingBottom + bottomPad) - s.clipToPadding = false - scrollBottomInsetApplied = true - logd("applyBottomInsetToScrollable: +$bottomPad to RecyclerView") + if (s.paddingBottom != desired) { + s.setPadding(s.paddingLeft, s.paddingTop, s.paddingRight, desired) + s.clipToPadding = false + logd("applyBottomInsetToScrollable(RecyclerView): base=${scrollBaseBottomPadding} inset=$bottomPad -> $desired") + } } + else -> Unit } + scrollBottomInsetApplied = true } private fun fullscreenTargetHeight(): Int { @@ -210,6 +225,8 @@ class NativeSheetView @JvmOverloads constructor( private val spring: TimeInterpolator = PathInterpolator(0.2f, 0f, 0f, 1f) + private val tmpScreenLoc = IntArray(2) + init { logd("init()") isClickable = false @@ -277,9 +294,7 @@ class NativeSheetView @JvmOverloads constructor( val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) logd("Insets IME bottom=${ime.bottom} (isPresented=$isPresented)") if (isPresented && !suppressAdjust) requestAdjust(animated = true) - if (fullscreenTopInsetPx > 0) { - applyBottomInsetToScrollableIfNeeded() - } + applyBottomInsetToScrollableIfNeeded() insets } } @@ -498,6 +513,8 @@ class NativeSheetView @JvmOverloads constructor( updateGeometryForHeight(targetH) placeOffscreenPreservingHeightByLayout() ensureContentConfiguredOnce() + applyBottomInsetToScrollableIfNeeded() + isAnimating = true isPresented = true logd("present: start -> targetH=$targetH restTop=$restTop") @@ -559,11 +576,13 @@ class NativeSheetView @JvmOverloads constructor( val sameH = (container.height == newH) if (sameTop && sameH) { logd("adjustToCurrentContent: nothing to change") + applyBottomInsetToScrollableIfNeeded() return } cachedHeightPx = newSheetH logd("adjustToCurrentContent: newH=$newH top=${newFrame.top} animated=$animated isAnimating=$isAnimating") applyContainerFrame(newFrame, animated && !isAnimating) + applyBottomInsetToScrollableIfNeeded() } fun dismiss() { @@ -610,6 +629,7 @@ class NativeSheetView @JvmOverloads constructor( val top = height - adjustedH return Frame(top, adjustedH) } + private fun applyContainerFrame(newFrame: Frame, animated: Boolean) { val normalized = if (fullscreenTopInsetPx > 0) { Frame(top = fullscreenTopInsetPx, height = fullscreenTargetHeight()) @@ -1058,9 +1078,9 @@ class NativeSheetView @JvmOverloads constructor( contentScanExhausted = false headerLayoutListenerAttached = false scrollBottomInsetApplied = false + scrollBaseBottomPadding = 0 post { captureRefsFromViewIfNeeded(childrenContainer) - applyBottomInsetToScrollableIfNeeded() isRescanning = false if (isPresented && contentView != null && !suppressAdjust) { @@ -1105,10 +1125,17 @@ class NativeSheetView @JvmOverloads constructor( isAnimating = false isPresented = false scrollBottomInsetApplied = false + scrollBaseBottomPadding = 0 hideContainerInstant() backdrop.alpha = 0f } + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + scrollBottomInsetApplied = false + applyBottomInsetToScrollableIfNeeded() + } + private fun canChildScrollUp(): Boolean { val v = scrollableChild ?: return false return getVerticalScrollOffset(v) > 0 @@ -1151,10 +1178,9 @@ class NativeSheetView @JvmOverloads constructor( super.onTouchEvent(cancel) dragHelper.cancel() cancel.recycle() - val locChild = IntArray(2) - child.getLocationOnScreen(locChild) - val childX = srcEvent.rawX - locChild[0] - val childY = srcEvent.rawY - locChild[1] + child.getLocationOnScreen(tmpScreenLoc) + val childX = srcEvent.rawX - tmpScreenLoc[0] + val childY = srcEvent.rawY - tmpScreenLoc[1] val now = SystemClock.uptimeMillis() val down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, childX, childY, srcEvent.metaState) child.dispatchTouchEvent(down) diff --git a/example/src/App.tsx b/example/src/App.tsx index 174b487..07d2ca8 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -96,6 +96,7 @@ const StackScreenSheetItem = memo((props: StackScreenSheetItemProps) => { style={styles.flex1} onDismissed={onDismissed} cornerRadius={18} + // fullscreenTopInset={120} > Date: Sun, 12 Oct 2025 04:07:56 +0300 Subject: [PATCH 11/12] feat(android): add containerBackgroundColor prop --- .../src/main/java/com/nativesheet/NativeSheetView.kt | 11 ++++++++++- .../java/com/nativesheet/NativeSheetViewManager.kt | 5 +++++ example/src/App.tsx | 3 ++- src/NativeSheetViewNativeComponent.ts | 2 ++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/nativesheet/NativeSheetView.kt b/android/src/main/java/com/nativesheet/NativeSheetView.kt index f69b16d..ae73f77 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetView.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetView.kt @@ -96,6 +96,15 @@ class NativeSheetView @JvmOverloads constructor( alpha = 0f } + private var containerBgColor: Int = Color.WHITE + + fun setContainerBackgroundColor(color: Int?) { + val resolved = color ?: Color.WHITE + containerBgColor = resolved + container.setBackgroundColor(resolved) + logd("setContainerBackgroundColor: #${Integer.toHexString(resolved)}") + } + private val container = SafeFrameLayout(context).apply { clipToOutline = true outlineProvider = object : ViewOutlineProvider() { @@ -104,7 +113,7 @@ class NativeSheetView @JvmOverloads constructor( outline.setConvexPath(outlinePath) } } - setBackgroundColor(Color.WHITE) + setBackgroundColor(containerBgColor) } internal val childrenContainer = RootChildren(context) diff --git a/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt b/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt index 233173b..bbaa9c1 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt @@ -38,6 +38,11 @@ class NativeSheetViewManager : view.setFullscreenTopInset(value) } + @ReactProp(name = "containerBackgroundColor", customType = "Color") + override fun setContainerBackgroundColor(view: NativeSheetView, color: Int?) { + view.setContainerBackgroundColor(color) + } + override fun dismiss(view: NativeSheetView) { view.dismiss() } diff --git a/example/src/App.tsx b/example/src/App.tsx index 07d2ca8..995d02f 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -96,7 +96,8 @@ const StackScreenSheetItem = memo((props: StackScreenSheetItemProps) => { style={styles.flex1} onDismissed={onDismissed} cornerRadius={18} - // fullscreenTopInset={120} + containerBackgroundColor="#fff" + // fullscreenTopInset={20} > >; onDismissed?: CodegenTypes.DirectEventHandler>; } From 8b0c223961b5d736a75439251709e018ecbfe896 Mon Sep 17 00:00:00 2001 From: bogoslavskiy Date: Sun, 12 Oct 2025 04:29:35 +0300 Subject: [PATCH 12/12] fix(android): bottom inset --- android/src/main/java/com/nativesheet/NativeSheetView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/com/nativesheet/NativeSheetView.kt b/android/src/main/java/com/nativesheet/NativeSheetView.kt index ae73f77..5a51a07 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetView.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetView.kt @@ -179,7 +179,7 @@ class NativeSheetView @JvmOverloads constructor( private fun bottomPadForScrollable(): Int { val insets = ViewCompat.getRootWindowInsets(this) val sys = insets?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: Insets.NONE - return if (fullscreenTopInsetPx > 0) fullscreenTopInsetPx else sys.bottom + return if (fullscreenTopInsetPx > 0) fullscreenTopInsetPx else sys.top } private fun applyBottomInsetToScrollableIfNeeded() { val s = scrollableChild ?: return