diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 37877df74cd448..9838a965e2cf3c 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -7113,14 +7113,14 @@ public final class com/facebook/react/views/scroll/ScrollEventType$Companion { public final fun getJSEventName (Lcom/facebook/react/views/scroll/ScrollEventType;)Ljava/lang/String; } -public class com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout : androidx/swiperefreshlayout/widget/SwipeRefreshLayout { +public final class com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout : androidx/swiperefreshlayout/widget/SwipeRefreshLayout { public fun (Lcom/facebook/react/bridge/ReactContext;)V public fun canChildScrollUp ()Z public fun onInterceptTouchEvent (Landroid/view/MotionEvent;)Z public fun onLayout (ZIIII)V public fun onTouchEvent (Landroid/view/MotionEvent;)Z public fun requestDisallowInterceptTouchEvent (Z)V - public fun setProgressViewOffset (F)V + public final fun setProgressViewOffset (F)V public fun setRefreshing (Z)V } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.java deleted file mode 100644 index 5fe3af15fa0faf..00000000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.java +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -package com.facebook.react.views.swiperefresh; - -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewConfiguration; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; -import com.facebook.infer.annotation.Nullsafe; -import com.facebook.react.bridge.ReactContext; -import com.facebook.react.uimanager.PixelUtil; -import com.facebook.react.uimanager.events.NativeGestureUtil; - -/** Basic extension of {@link SwipeRefreshLayout} with ReactNative-specific functionality. */ -@Nullsafe(Nullsafe.Mode.LOCAL) -public class ReactSwipeRefreshLayout extends SwipeRefreshLayout { - - private static final float DEFAULT_CIRCLE_TARGET = 64; - - private boolean mDidLayout = false; - private boolean mRefreshing = false; - private float mProgressViewOffset = 0; - private int mTouchSlop; - private float mPrevTouchX; - private boolean mIntercepted; - private boolean mNativeGestureStarted = false; - - public ReactSwipeRefreshLayout(ReactContext reactContext) { - super(reactContext); - mTouchSlop = ViewConfiguration.get(reactContext).getScaledTouchSlop(); - } - - @Override - public void setRefreshing(boolean refreshing) { - mRefreshing = refreshing; - - // `setRefreshing` must be called after the initial layout otherwise it - // doesn't work when mounting the component with `refreshing = true`. - // Known Android issue: https://code.google.com/p/android/issues/detail?id=77712 - if (mDidLayout) { - super.setRefreshing(refreshing); - } - } - - public void setProgressViewOffset(float offset) { - mProgressViewOffset = offset; - - // The view must be measured before calling `getProgressCircleDiameter` so - // don't do it before the initial layout. - if (mDidLayout) { - int diameter = getProgressCircleDiameter(); - int start = Math.round(PixelUtil.toPixelFromDIP(offset)) - diameter; - int end = Math.round(PixelUtil.toPixelFromDIP(offset + DEFAULT_CIRCLE_TARGET) - diameter); - setProgressViewOffset(false, start, end); - } - } - - @Override - public void onLayout(boolean changed, int left, int top, int right, int bottom) { - super.onLayout(changed, left, top, right, bottom); - - if (!mDidLayout) { - mDidLayout = true; - - // Update values that must be set after initial layout. - setProgressViewOffset(mProgressViewOffset); - setRefreshing(mRefreshing); - } - } - - @Override - public boolean canChildScrollUp() { - View firstChild = getChildAt(0); - if (firstChild != null) { - return firstChild.canScrollVertically(-1); - } else { - return super.canChildScrollUp(); - } - } - - /** - * {@link SwipeRefreshLayout} overrides {@link ViewGroup#requestDisallowInterceptTouchEvent} and - * swallows it. This means that any component underneath SwipeRefreshLayout will now interact - * incorrectly with Views that are above SwipeRefreshLayout. We fix that by transmitting the call - * to this View's parents. - */ - @Override - public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) { - if (getParent() != null) { - getParent().requestDisallowInterceptTouchEvent(disallowIntercept); - } - } - - @Override - public boolean onInterceptTouchEvent(MotionEvent ev) { - if (shouldInterceptTouchEvent(ev) && super.onInterceptTouchEvent(ev)) { - NativeGestureUtil.notifyNativeGestureStarted(this, ev); - mNativeGestureStarted = true; - - // If the pull-to-refresh gesture is interrupted by a parent with its own - // onInterceptTouchEvent then the refresh indicator gets stuck on-screen - // so we ask the parent to not intercept this touch event after it started - if (getParent() != null) { - getParent().requestDisallowInterceptTouchEvent(true); - } - - return true; - } - return false; - } - - @Override - public boolean onTouchEvent(MotionEvent ev) { - int action = ev.getActionMasked(); - if (action == MotionEvent.ACTION_UP && mNativeGestureStarted) { - NativeGestureUtil.notifyNativeGestureEnded(this, ev); - mNativeGestureStarted = false; - } - return super.onTouchEvent(ev); - } - - /** - * {@link SwipeRefreshLayout} completely bypasses ViewGroup's "disallowIntercept" by overriding - * {@link ViewGroup#onInterceptTouchEvent} and never calling super.onInterceptTouchEvent(). This - * means that horizontal scrolls will always be intercepted, even though they shouldn't, so we - * have to check for that manually here. - */ - private boolean shouldInterceptTouchEvent(MotionEvent ev) { - switch (ev.getAction()) { - case MotionEvent.ACTION_DOWN: - mPrevTouchX = ev.getX(); - mIntercepted = false; - break; - - case MotionEvent.ACTION_MOVE: - final float eventX = ev.getX(); - final float xDiff = Math.abs(eventX - mPrevTouchX); - - if (mIntercepted || xDiff > mTouchSlop) { - mIntercepted = true; - return false; - } - } - return true; - } -} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.kt new file mode 100644 index 00000000000000..e3201890f4c478 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.swiperefresh + +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.events.NativeGestureUtil + +/** Basic extension of [SwipeRefreshLayout] with ReactNative-specific functionality. */ +public class ReactSwipeRefreshLayout(reactContext: ReactContext) : + SwipeRefreshLayout(reactContext) { + + private var didLayout: Boolean = false + private var refreshing: Boolean = false + private var progressViewOffset: Float = 0f + private val touchSlop: Int = ViewConfiguration.get(reactContext).scaledTouchSlop + private var prevTouchX: Float = 0f + private var intercepted: Boolean = false + private var nativeGestureStarted: Boolean = false + + public override fun setRefreshing(refreshing: Boolean) { + this.refreshing = refreshing + + // `setRefreshing` must be called after the initial layout otherwise it + // doesn't work when mounting the component with `refreshing = true`. + // Known Android issue: https://code.google.com/p/android/issues/detail?id=77712 + if (didLayout) { + super.setRefreshing(refreshing) + } + } + + public fun setProgressViewOffset(offset: Float) { + progressViewOffset = offset + + // The view must be measured before calling `getProgressCircleDiameter` so + // don't do it before the initial layout. + if (didLayout) { + val diameter = progressCircleDiameter + val start = Math.round(PixelUtil.toPixelFromDIP(offset)) - diameter + val end = Math.round(PixelUtil.toPixelFromDIP(offset + DEFAULT_CIRCLE_TARGET)) - diameter + setProgressViewOffset(false, start, end) + } + } + + public override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + + if (!didLayout) { + didLayout = true + + // Update values that must be set after initial layout. + setProgressViewOffset(progressViewOffset) + setRefreshing(refreshing) + } + } + + public override fun canChildScrollUp(): Boolean { + val firstChild = getChildAt(0) + return firstChild?.canScrollVertically(-1) ?: super.canChildScrollUp() + } + + /** + * [SwipeRefreshLayout] overrides [ViewGroup.requestDisallowInterceptTouchEvent] and swallows it. + * This means that any component underneath SwipeRefreshLayout will now interact incorrectly with + * Views that are above SwipeRefreshLayout. We fix that by transmitting the call to this View's + * parents. + */ + public override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + parent?.requestDisallowInterceptTouchEvent(disallowIntercept) + } + + public override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + if (shouldInterceptTouchEvent(ev) && super.onInterceptTouchEvent(ev)) { + NativeGestureUtil.notifyNativeGestureStarted(this, ev) + nativeGestureStarted = true + + // If the pull-to-refresh gesture is interrupted by a parent with its own + // onInterceptTouchEvent then the refresh indicator gets stuck on-screen + // so we ask the parent to not intercept this touch event after it started + parent?.requestDisallowInterceptTouchEvent(true) + + return true + } + return false + } + + public override fun onTouchEvent(ev: MotionEvent): Boolean { + if (ev.actionMasked == MotionEvent.ACTION_UP && nativeGestureStarted) { + NativeGestureUtil.notifyNativeGestureEnded(this, ev) + nativeGestureStarted = false + } + return super.onTouchEvent(ev) + } + + /** + * [SwipeRefreshLayout] completely bypasses ViewGroup's "disallowIntercept" by overriding + * [ViewGroup.onInterceptTouchEvent] and never calling super.onInterceptTouchEvent(). This means + * that horizontal scrolls will always be intercepted, even though they shouldn't, so we have to + * check for that manually here. + */ + private fun shouldInterceptTouchEvent(ev: MotionEvent): Boolean { + when (ev.action) { + MotionEvent.ACTION_DOWN -> { + prevTouchX = ev.x + intercepted = false + } + MotionEvent.ACTION_MOVE -> { + val eventX = ev.x + val xDiff = Math.abs(eventX - prevTouchX) + + if (intercepted || xDiff > touchSlop) { + intercepted = true + return false + } + } + } + return true + } + + private companion object { + private const val DEFAULT_CIRCLE_TARGET = 64f + } +}