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/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..5a51a07 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetView.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetView.kt @@ -1,15 +1,1201 @@ package com.nativesheet +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.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.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 kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +class NativeSheetView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private val TAG = "NativeSheetView" + 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) } + + 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 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 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() { + override fun getOutline(view: View, outline: Outline) { + updateOutlinePathIfNeeded(view.width, view.height, cornerRadiusPx) + outline.setConvexPath(outlinePath) + } + } + setBackgroundColor(containerBgColor) + } + + internal val childrenContainer = RootChildren(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 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 velocityDismissThresholdPxPerSec = 1200f * resources.displayMetrics.density + + 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 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 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.top + } + private fun applyBottomInsetToScrollableIfNeeded() { + val s = scrollableChild ?: return + val bottomPad = bottomPadForScrollable() + if (bottomPad <= 0) return + + if (!scrollBottomInsetApplied) { + scrollBaseBottomPadding = s.paddingBottom + } + + val desired = scrollBaseBottomPadding + bottomPad + + when (s) { + is 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 -> { + 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 -> { + 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 { + 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 && !suppressAdjust) { + logd("GlobalLayout -> requestAdjust(animated=true)") + requestAdjust(animated = true) + } + } + + private val spring: TimeInterpolator = PathInterpolator(0.2f, 0f, 0f, 1f) + + private val tmpScreenLoc = IntArray(2) + + init { + logd("init()") + isClickable = false + clipChildren = false + clipToPadding = false + 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 { + 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 + 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 + 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) { + 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 > velocityDismissThresholdPxPerSec) || (curTop > restTop + dragRange / 2) + logd("onViewReleased: top=$curTop, yvel=$yvel, shouldDismiss=$shouldDismiss") + + if (shouldDismiss) { + suppressAdjust = true + adjustPosted = false + allowDragNow = false + isAnimating = true + isPresented = false + animateBackdrop(to = 0f) + + settleTo(height) { + hideContainerInstant() + isAnimating = false + suppressAdjust = false + 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()) + logd("Insets IME bottom=${ime.bottom} (isPresented=$isPresented)") + if (isPresented && !suppressAdjust) requestAdjust(animated = true) + applyBottomInsetToScrollableIfNeeded() + 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) { scrollEnabled = enabled } + override fun onTouch(v: View, ev: MotionEvent): Boolean { + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { + lastY = ev.y; passedSlop = false + requestParentIntercept(true) + return false + } + MotionEvent.ACTION_MOVE -> { + val dy = ev.y - lastY + if (!passedSlop && kotlin.math.abs(dy) > touchSlop) passedSlop = true + lastY = ev.y + if (passedSlop && dy > 0f && getOffset() <= 0) { + requestParentIntercept(false) + } + if (!scrollEnabled) return passedSlop + return false + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> return false + else -> return false + } + } + } + + private fun attachChildTouchListenerIfNeeded() { + val v = scrollableChild ?: return + if (childDelegate != null) return + childDelegate = ChildTouchDelegate( + getOffset = { getVerticalScrollOffset(v) }, + requestParentIntercept = { disallow -> v.parent?.requestDisallowInterceptTouchEvent(disallow) }, + touchSlop = ViewConfiguration.get(context).scaledTouchSlop + ) + v.setOnTouchListener(childDelegate) + } + + private fun detachChildTouchListener() { + val v = scrollableChild ?: return + v.setOnTouchListener(null) + childDelegate = null + } + + private fun updateScrollEnabledForContent(currentSheetH: Int) { + val maxH = contentMaximumHeight() + val enableScroll = currentSheetH >= maxH + childDelegate?.setScrollEnabled(enableScroll) + } + + private fun recomputeHeights() { + ensureRefsIfMissing() + val hView = headerView + val cView = contentView + 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 + logd("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 + } + logd("currentContentHeight: $h (scrollable=${scroll?.javaClass?.simpleName})") + return h + } + + private fun contentMaximumHeight(): Int { + val insets = ViewCompat.getRootWindowInsets(this) + val ime = insets?.getInsets(WindowInsetsCompat.Type.ime()) ?: Insets.NONE + + 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 + + fun setBackdropOpacity(value: Float) { + backdropMaxAlpha = value.coerceIn(0f, 1f) + backdrop.alpha = backdrop.alpha.coerceAtMost(backdropMaxAlpha) + logd("setBackdropOpacity: $backdropMaxAlpha") + } + + fun setCornerRadius(valueDp: Float) { + val px = valueDp * resources.displayMetrics.density + if (px == cornerRadiusPx) return + cornerRadiusPx = px + container.invalidateOutline() + logd("setCornerRadiusPx: $cornerRadiusPx") + } + + fun present(optionalHeightPx: Int? = null) { + if (isAnimating || isPresented) { + logd("present: skip (isAnimating=$isAnimating isPresented=$isPresented)") + return + } + if (width == 0 || height == 0) { + logd("present: defer (no size yet)") + 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() + applyBottomInsetToScrollableIfNeeded() + + isAnimating = true + isPresented = true + animateBackdrop(to = backdropMaxAlpha) + settleTo(restTop) { + isAnimating = false + dispatchEvent("onAppeared", Arguments.createMap()) + } + return + } + + recomputeHeights() + if (headerHeight == 0) { + post { if (isPresented && !suppressAdjust) requestAdjust(true) } + } + val contentH = optionalHeightPx ?: currentContentHeight() + val sheetH = calcSheetHeight(contentH) + if (sheetH <= 0) { + logw("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() + applyBottomInsetToScrollableIfNeeded() + + isAnimating = true + isPresented = true + logd("present: start -> targetH=$targetH restTop=$restTop") + animateBackdrop(to = backdropMaxAlpha) + settleTo(restTop) { + isAnimating = false + logd("present: finished at top=$restTop") + dispatchEvent("onAppeared", Arguments.createMap()) + } + } + + 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") + applyBottomInsetToScrollableIfNeeded() + return + } + cachedHeightPx = targetH + logd("adjustToCurrentContent(fullscreen): newH=${newFrame.height} top=${newFrame.top} animated=$animated isAnimating=$isAnimating") + applyContainerFrame(newFrame, animated && !isAnimating) + applyBottomInsetToScrollableIfNeeded() + return + } + + ensureRefsIfMissing() + if (contentView == null) { + logd("adjustToCurrentContent: no contentView") + 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) { + 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() { + if (!isPresented || isAnimating) { + 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() + } + } + + 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): 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 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 + 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() + logd("applyContainerFrame: instant -> top=$newTop h=$newH") + return + } + layoutContainer(oldTop, newH) + updateBackdropForTop(container.top) + val started = dragHelper.smoothSlideViewTo(container, container.left, newTop) + logd("applyContainerFrame: animated -> start=$started from=$oldTop to=$newTop h=$newH") + if (started) { + postOnAnimation { + continueSettling { + updateScrollEnabledForContent(newH) + unfreezeBackdrop() + logd("applyContainerFrame: animated finished at top=${container.top}") + } + } + } else { + layoutContainer(newTop, newH) + updateBackdropForTop(container.top) + updateScrollEnabledForContent(newH) + unfreezeBackdrop() + logd("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 + logd("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 + logd("hideContainerInstant()") + } + + private fun placeOffscreenPreservingHeightByLayout() { + val h = container.height + container.layout(0, height, width, height + h) + updateBackdropForTop(container.top) + logd("placeOffscreenPreservingHeightByLayout: h=$h") + } + + 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 + container.layout(0, restTop, width, restTop + targetH) + updateBackdropForTop(container.top) + logd("updateGeometryForHeight: targetH=$targetH restTop=$restTop") + } + + private fun freezeBackdrop() { + backdropFrozenAlpha = backdrop.alpha + backdropFrozen = true + logd("freezeBackdrop: alpha=$backdropFrozenAlpha") + } + + private fun unfreezeBackdrop() { + backdropFrozen = false + updateBackdropForTop(container.top) + logd("unfreezeBackdrop()") + } + + private fun updateBackdropForTop(curTop: Int) { + if (!isPresented) { + backdrop.alpha = 0f + return + } + 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) + logd("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) + logd("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() { + logd("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) + logd("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 + logd("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) + logd("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) { + 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) { + logd("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 >= l && x < r && y >= t && y < b + } + + private fun getTestId(view: View): String? { + (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) { + logd("captureRefs: headerView found (${headerView!!.javaClass.simpleName})") + } + if (!beforeContent && contentView != null) { + logd("captureRefs: contentView found (${contentView!!.javaClass.simpleName})") + } + contentView?.let { v -> + if (scrollableChild == null) { + scrollableChild = findAndroidScrollableIn(v) + if (scrollableChild != null) { + logd("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 + logd("scan: headerView tagged by testID") + if (!headerLayoutListenerAttached) { + headerView?.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + if (isPresented && fullscreenTopInsetPx == 0 && !suppressAdjust) { + logd("header onLayout -> requestAdjust(true)") + requestAdjust(true) + } + } + headerLayoutListenerAttached = true + } + } + if (!contentScanExhausted && contentView == null && id == "sheet-content") { + contentView = view + contentScanExhausted = true + logd("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) { + logd("tap-protector header: requestDisallowIntercept(true)") + v.parent?.requestDisallowInterceptTouchEvent(true) + } + false + } + if (scrollableChild == null) { + contentView?.setOnTouchListener { v, e -> + if (e.actionMasked == MotionEvent.ACTION_DOWN) { + logd("tap-protector content(no-scroll): requestDisallowIntercept(true)") + v.parent?.requestDisallowInterceptTouchEvent(true) + } + false + } + } + } + + private fun ensureContentConfiguredOnce() { + attachChildTouchListenerIfNeeded() + installTapProtectors() + if (contentLayoutListenerAdded) { + applyBottomInsetToScrollableIfNeeded() + return + } + val contentChild = scrollContentChild + if (contentChild != null) { + logd("ensureContentConfiguredOnce: add listeners to scrollContentChild") + contentChild.addOnLayoutChangeListener(contentLayoutChangeListener) + contentGlobalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { + if (!isPresented) return@OnGlobalLayoutListener + if (fullscreenTopInsetPx == 0 && !suppressAdjust) { + logd("content globalLayout -> requestAdjust(true)") + requestAdjust(true) + } + } + contentChild.viewTreeObserver.addOnGlobalLayoutListener(contentGlobalLayoutListener) + contentLayoutListenerAdded = true + applyBottomInsetToScrollableIfNeeded() + return + } + val cv = contentView + if (cv != null) { + logd("ensureContentConfiguredOnce: add listeners to contentView") + cv.addOnLayoutChangeListener(contentLayoutChangeListener) + contentGlobalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { + if (!isPresented) return@OnGlobalLayoutListener + if (fullscreenTopInsetPx == 0 && !suppressAdjust) { + logd("contentView globalLayout -> requestAdjust(true)") + requestAdjust(true) + } + } + cv.viewTreeObserver.addOnGlobalLayoutListener(contentGlobalLayoutListener) + contentLayoutListenerAdded = true + applyBottomInsetToScrollableIfNeeded() + } + } + + private val contentLayoutChangeListener = OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + if (!isPresented) return@OnLayoutChangeListener + if (fullscreenTopInsetPx == 0 && !suppressAdjust) { + logd("content onLayoutChange -> requestAdjust(true)") + requestAdjust(animated = true) + } + } + + private fun removeContentObservers() { + if (!contentLayoutListenerAdded) return + logd("removeContentObservers()") + scrollContentChild?.let { cc -> + try { cc.removeOnLayoutChangeListener(contentLayoutChangeListener) } 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 { + val vto = cv.viewTreeObserver + if (vto.isAlive) contentGlobalLayoutListener?.let { l -> vto.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 + logd("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) { + logd("onChildMounted: ${child.javaClass.simpleName}") + if (shouldContinueScanning()) captureRefsFromViewIfNeeded(child) + } + + internal fun requestFullRescan() { + if (isRescanning) return + isRescanning = true + logd("requestFullRescan()") + detachChildTouchListener() + removeContentObservers() + scrollableChild = null + scrollContentChild = null + headerView = null + contentView = null + headerScanExhausted = false + contentScanExhausted = false + headerLayoutListenerAttached = false + scrollBottomInsetApplied = false + scrollBaseBottomPadding = 0 + post { + captureRefsFromViewIfNeeded(childrenContainer) + applyBottomInsetToScrollableIfNeeded() + isRescanning = false + if (isPresented && contentView != null && !suppressAdjust) { + logd("requestFullRescan -> requestAdjust(true)") + requestAdjust(animated = true) + } + } + } + + override fun onAttachedToWindow() { + 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) } + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + logd("onDetachedFromWindow") + viewTreeObserver.removeOnGlobalLayoutListener(layoutListener) + detachChildTouchListener() + removeContentObservers() + headerView = null + contentView = null + scrollableChild = null + scrollContentChild = null + headerScanExhausted = false + contentScanExhausted = false + headerLayoutListenerAttached = false + cachedHeightPx = 0 + 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 + } + + 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 + 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 + ) + super.onTouchEvent(cancel) + dragHelper.cancel() + cancel.recycle() + 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) + 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..bbaa9c1 100644 --- a/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt +++ b/android/src/main/java/com/nativesheet/NativeSheetViewManager.kt @@ -1,38 +1,77 @@ package com.nativesheet -import android.graphics.Color +import android.view.View import com.facebook.react.module.annotations.ReactModule -import com.facebook.react.uimanager.SimpleViewManager 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 -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) + } + + @ReactProp(name = "fullscreenTopInset", defaultFloat = 0f) + override fun setFullscreenTopInset(view: NativeSheetView, value: Float) { + 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() } - override fun getDelegate(): ViewManagerDelegate? { - return mDelegate + override fun onAfterUpdateTransaction(view: NativeSheetView) { + super.onAfterUpdateTransaction(view) } - override fun getName(): String { - return NAME + override fun addView(parent: NativeSheetView, child: View, index: Int) { + val safeIndex = index.coerceIn(0, parent.reactChildCount()) + parent.addReactChild(child, safeIndex) } - public override fun createViewInstance(context: ThemedReactContext): NativeSheetView { - return NativeSheetView(context) + override fun getChildAt(parent: NativeSheetView, index: Int): View = + parent.reactChildAt(index) + + override fun getChildCount(parent: NativeSheetView): Int = + parent.reactChildCount() + + override fun removeViewAt(parent: NativeSheetView, index: Int) { + parent.removeReactChildAt(index) } - @ReactProp(name = "color") - override fun setColor(view: NativeSheetView?, color: String?) { - view?.setBackgroundColor(Color.parseColor(color)) + override fun needsCustomLayoutForChildren(): Boolean = false + + override fun addEventEmitters(reactContext: ThemedReactContext, view: NativeSheetView) { + UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)?.let { dispatcher -> + view.setEventDispatcher(dispatcher) + } } companion object { 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)) + } +} 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 diff --git a/example/src/App.tsx b/example/src/App.tsx index a215b04..995d02f 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,3 +1,9 @@ +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, useSheets, @@ -5,11 +11,6 @@ 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(); @@ -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) + ); + }} + > + + + ))} + + + ); } @@ -93,6 +96,8 @@ const StackScreenSheetItem = memo((props: StackScreenSheetItemProps) => { style={styles.flex1} onDismissed={onDismissed} cornerRadius={18} + containerBackgroundColor="#fff" + // fullscreenTopInset={20} > { const [list, setList] = useState([{ title: 1 }]); const { setSheets, sheets } = useSheets(); const { dismiss } = useDismiss(); + const insets = useSafeAreaInsets(); return ( <> - + {Platform.OS === 'android' && ( { {list.map((i, key) => ( diff --git a/src/NativeSheetViewNativeComponent.ts b/src/NativeSheetViewNativeComponent.ts index 72e2630..8c3e83e 100644 --- a/src/NativeSheetViewNativeComponent.ts +++ b/src/NativeSheetViewNativeComponent.ts @@ -4,11 +4,14 @@ import { codegenNativeCommands, type CodegenTypes, type HostComponent, + type ColorValue, } from 'react-native'; export interface NativeProps extends ViewProps { backdropOpacity?: CodegenTypes.Float; cornerRadius?: CodegenTypes.Float; + fullscreenTopInset?: CodegenTypes.Float; + containerBackgroundColor?: ColorValue; onAppeared?: CodegenTypes.DirectEventHandler>; onDismissed?: CodegenTypes.DirectEventHandler>; }