From 48d542140c3611626b5110d2d9278e48683907f7 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 26 Mar 2026 13:36:48 +0100 Subject: [PATCH 01/15] Revert contents-based button --- .../gesturehandler/RNGestureHandlerPackage.kt | 5 - .../RNGestureHandlerButtonViewManager.kt | 29 ++-- ...NGestureHandlerButtonWrapperViewManager.kt | 30 ---- .../RNGestureHandlerButtonComponentView.mm | 25 +-- .../apple/RNGestureHandlerButtonWrapper.h | 19 --- .../apple/RNGestureHandlerButtonWrapper.mm | 46 ------ .../react-native-gesture-handler/package.json | 3 +- .../react-native.config.js | 1 - .../ComponentDescriptors.h | 1 - ...eHandlerButtonWrapperComponentDescriptor.h | 32 ---- ...NGestureHandlerButtonWrapperShadowNode.cpp | 102 ------------ .../RNGestureHandlerButtonWrapperShadowNode.h | 56 ------- .../RNGestureHandlerButtonWrapperState.h | 32 ---- .../src/components/GestureButtons.tsx | 3 +- .../src/components/GestureHandlerButton.tsx | 156 +----------------- ...tureHandlerButtonWrapperNativeComponent.ts | 11 -- 16 files changed, 20 insertions(+), 531 deletions(-) delete mode 100644 packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonWrapperViewManager.kt delete mode 100644 packages/react-native-gesture-handler/apple/RNGestureHandlerButtonWrapper.h delete mode 100644 packages/react-native-gesture-handler/apple/RNGestureHandlerButtonWrapper.mm delete mode 100644 packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperComponentDescriptor.h delete mode 100644 packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperShadowNode.cpp delete mode 100644 packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperShadowNode.h delete mode 100644 packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperState.h delete mode 100644 packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonWrapperNativeComponent.ts diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/RNGestureHandlerPackage.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/RNGestureHandlerPackage.kt index d5c7001e20..95f071671f 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/RNGestureHandlerPackage.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/RNGestureHandlerPackage.kt @@ -11,7 +11,6 @@ import com.facebook.react.module.model.ReactModuleInfo import com.facebook.react.module.model.ReactModuleInfoProvider import com.facebook.react.uimanager.ViewManager import com.swmansion.gesturehandler.react.RNGestureHandlerButtonViewManager -import com.swmansion.gesturehandler.react.RNGestureHandlerButtonWrapperViewManager import com.swmansion.gesturehandler.react.RNGestureHandlerDetectorViewManager import com.swmansion.gesturehandler.react.RNGestureHandlerModule import com.swmansion.gesturehandler.react.RNGestureHandlerRootViewManager @@ -35,9 +34,6 @@ class RNGestureHandlerPackage : RNGestureHandlerDetectorViewManager.REACT_CLASS to ModuleSpec.viewManagerSpec { RNGestureHandlerDetectorViewManager() }, - RNGestureHandlerButtonWrapperViewManager.REACT_CLASS to ModuleSpec.viewManagerSpec { - RNGestureHandlerButtonWrapperViewManager() - }, ) } @@ -45,7 +41,6 @@ class RNGestureHandlerPackage : RNGestureHandlerRootViewManager(), RNGestureHandlerButtonViewManager(), RNGestureHandlerDetectorViewManager(), - RNGestureHandlerButtonWrapperViewManager(), ) override fun getViewManagerNames(reactContext: ReactApplicationContext) = viewManagers.keys.toList() diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 7a013ad041..058ef9e1a7 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -391,14 +391,12 @@ class RNGestureHandlerButtonViewManager : } private fun applyStartAnimationState() { - (parent as? ViewGroup)?.let { - if (activeOpacity != 1.0f || defaultOpacity != 1.0f) { - it.alpha = defaultOpacity - } - if (activeScale != 1.0f || defaultScale != 1.0f) { - it.scaleX = defaultScale - it.scaleY = defaultScale - } + if (activeOpacity != 1.0f || defaultOpacity != 1.0f) { + alpha = defaultOpacity + } + if (activeScale != 1.0f || defaultScale != 1.0f) { + scaleX = defaultScale + scaleY = defaultScale } underlayDrawable?.alpha = (defaultUnderlayOpacity * 255).toInt() } @@ -413,15 +411,12 @@ class RNGestureHandlerButtonViewManager : currentAnimator?.cancel() val animators = ArrayList() - if (hasOpacity || hasScale) { - val parent = this.parent as? ViewGroup ?: return - if (hasOpacity) { - animators.add(ObjectAnimator.ofFloat(parent, "alpha", opacity)) - } - if (hasScale) { - animators.add(ObjectAnimator.ofFloat(parent, "scaleX", scale)) - animators.add(ObjectAnimator.ofFloat(parent, "scaleY", scale)) - } + if (hasOpacity) { + animators.add(ObjectAnimator.ofFloat(this, "alpha", opacity)) + } + if (hasScale) { + animators.add(ObjectAnimator.ofFloat(this, "scaleX", scale)) + animators.add(ObjectAnimator.ofFloat(this, "scaleY", scale)) } if (hasUnderlay) { animators.add(ObjectAnimator.ofInt(underlayDrawable!!, "alpha", (underlayOpacity * 255).toInt())) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonWrapperViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonWrapperViewManager.kt deleted file mode 100644 index 7137243bd7..0000000000 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonWrapperViewManager.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.swmansion.gesturehandler.react - -import com.facebook.react.module.annotations.ReactModule -import com.facebook.react.uimanager.ThemedReactContext -import com.facebook.react.uimanager.ViewGroupManager -import com.facebook.react.uimanager.ViewManagerDelegate -import com.facebook.react.viewmanagers.RNGestureHandlerButtonWrapperManagerDelegate -import com.facebook.react.viewmanagers.RNGestureHandlerButtonWrapperManagerInterface -import com.facebook.react.views.view.ReactViewGroup - -@ReactModule(name = RNGestureHandlerButtonWrapperViewManager.REACT_CLASS) -class RNGestureHandlerButtonWrapperViewManager : - ViewGroupManager(), - RNGestureHandlerButtonWrapperManagerInterface { - private val mDelegate: ViewManagerDelegate = - RNGestureHandlerButtonWrapperManagerDelegate< - ReactViewGroup, - RNGestureHandlerButtonWrapperViewManager, - >(this) - - override fun getDelegate(): ViewManagerDelegate = mDelegate - - override fun getName() = REACT_CLASS - - override fun createViewInstance(reactContext: ThemedReactContext) = ReactViewGroup(reactContext) - - companion object { - const val REACT_CLASS = "RNGestureHandlerButtonWrapper" - } -} diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index a87ca71a71..616b129d87 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -56,6 +56,7 @@ - (instancetype)initWithFrame:(CGRect)frame static const auto defaultProps = std::make_shared(); _props = defaultProps; _buttonView = [[RNGestureHandlerButton alloc] initWithFrame:self.bounds]; + _buttonView.animationTarget = self; self.contentView = _buttonView; } @@ -63,26 +64,6 @@ - (instancetype)initWithFrame:(CGRect)frame return self; } -#if !TARGET_OS_OSX -- (void)willMoveToSuperview:(RNGHUIView *)newSuperview -{ - [super willMoveToSuperview:newSuperview]; - _buttonView.animationTarget = newSuperview; - if (newSuperview != nil) { - [_buttonView applyStartAnimationState]; - } -} -#else -- (void)viewWillMoveToSuperview:(RNGHUIView *)newSuperview -{ - [super viewWillMoveToSuperview:newSuperview]; - _buttonView.animationTarget = newSuperview; - if (newSuperview != nil) { - [_buttonView applyStartAnimationState]; - } -} -#endif - - (void)mountChildComponentView:(RNGHUIView *)childComponentView index:(NSInteger)index { [_buttonView mountChildComponentView:childComponentView index:index]; @@ -268,9 +249,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & } [super updateProps:props oldProps:oldProps]; - if (_buttonView.animationTarget != nil) { - [_buttonView applyStartAnimationState]; - } + [_buttonView applyStartAnimationState]; } #if !TARGET_OS_OSX diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonWrapper.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonWrapper.h deleted file mode 100644 index c94ecf946e..0000000000 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonWrapper.h +++ /dev/null @@ -1,19 +0,0 @@ -#if !TARGET_OS_OSX -#import -#else -#import -#endif - -#import - -#import - -using namespace facebook::react; - -NS_ASSUME_NONNULL_BEGIN - -@interface RNGestureHandlerButtonWrapper : RCTViewComponentView - -@end - -NS_ASSUME_NONNULL_END diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonWrapper.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonWrapper.mm deleted file mode 100644 index 526c7743d6..0000000000 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonWrapper.mm +++ /dev/null @@ -1,46 +0,0 @@ -#import "RNGestureHandlerButtonWrapper.h" -#import "RNGestureHandlerButtonWrapperComponentDescriptor.h" -#import "RNGestureHandlerModule.h" - -#import -#import - -#import -#import -#import - -@interface RNGestureHandlerButtonWrapper () -@end - -@implementation RNGestureHandlerButtonWrapper - -#if TARGET_OS_OSX -+ (BOOL)shouldBeRecycled -{ - return NO; -} -#endif - -- (instancetype)initWithFrame:(CGRect)frame -{ - if (self = [super initWithFrame:frame]) { - static const auto defaultProps = std::make_shared(); - _props = defaultProps; - } - - return self; -} - -#pragma mark - RCTComponentViewProtocol - -+ (ComponentDescriptorProvider)componentDescriptorProvider -{ - return concreteComponentDescriptorProvider(); -} - -@end - -Class RNGestureHandlerButtonWrapperCls(void) -{ - return RNGestureHandlerButtonWrapper.class; -} diff --git a/packages/react-native-gesture-handler/package.json b/packages/react-native-gesture-handler/package.json index a7bb000328..eb39f6eaf3 100644 --- a/packages/react-native-gesture-handler/package.json +++ b/packages/react-native-gesture-handler/package.json @@ -128,8 +128,7 @@ "ios": { "componentProvider": { "RNGestureHandlerButton": "RNGestureHandlerButtonComponentView", - "RNGestureHandlerDetector": "RNGestureHandlerDetector", - "RNGestureHandlerButtonWrapper": "RNGestureHandlerButtonWrapper" + "RNGestureHandlerDetector": "RNGestureHandlerDetector" } } }, diff --git a/packages/react-native-gesture-handler/react-native.config.js b/packages/react-native-gesture-handler/react-native.config.js index 46e3de4682..d19502f02d 100644 --- a/packages/react-native-gesture-handler/react-native.config.js +++ b/packages/react-native-gesture-handler/react-native.config.js @@ -4,7 +4,6 @@ module.exports = { android: { componentDescriptors: [ 'RNGestureHandlerDetectorComponentDescriptor', - 'RNGestureHandlerButtonWrapperComponentDescriptor', ], cmakeListsPath: './CMakeLists.txt', }, diff --git a/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/ComponentDescriptors.h b/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/ComponentDescriptors.h index 5621db20e7..9ca21c682c 100644 --- a/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/ComponentDescriptors.h +++ b/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/ComponentDescriptors.h @@ -15,7 +15,6 @@ #include #include -#include #include namespace facebook::react { diff --git a/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperComponentDescriptor.h b/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperComponentDescriptor.h deleted file mode 100644 index c3323ae823..0000000000 --- a/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperComponentDescriptor.h +++ /dev/null @@ -1,32 +0,0 @@ - -/** - * This code was generated by - * [react-native-codegen](https://www.npmjs.com/package/react-native-codegen). - * - * Do not edit this file as changes may cause incorrect behavior and will be - * lost once the code is regenerated. - * - * @generated by codegen project: GenerateComponentDescriptorH.js - */ - -#pragma once - -#include - -#include "RNGestureHandlerButtonWrapperShadowNode.h" - -namespace facebook::react { - -class RNGestureHandlerButtonWrapperComponentDescriptor final - : public ConcreteComponentDescriptor< - RNGestureHandlerButtonWrapperShadowNode> { - using ConcreteComponentDescriptor::ConcreteComponentDescriptor; - void adopt(ShadowNode &shadowNode) const override { - react_native_assert( - dynamic_cast(&shadowNode)); - - ConcreteComponentDescriptor::adopt(shadowNode); - } -}; - -} // namespace facebook::react diff --git a/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperShadowNode.cpp b/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperShadowNode.cpp deleted file mode 100644 index 682baf51f8..0000000000 --- a/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperShadowNode.cpp +++ /dev/null @@ -1,102 +0,0 @@ - -#include - -#include "RNGestureHandlerButtonWrapperShadowNode.h" - -namespace facebook::react { - -extern const char RNGestureHandlerButtonWrapperComponentName[] = - "RNGestureHandlerButtonWrapper"; - -void RNGestureHandlerButtonWrapperShadowNode::initialize() { - // When the button wrapper is cloned and has a child node, the child node - // should be cloned as well to ensure it is mutable. - if (!getChildren().empty()) { - prepareChildren(); - } -} - -void RNGestureHandlerButtonWrapperShadowNode::prepareChildren() { - const auto &children = getChildren(); - react_native_assert( - children.size() == 1 && - "RNGestureHandlerButtonWrapper received more than one child"); - - const auto directChild = children[0]; - react_native_assert( - directChild->getChildren().size() == 1 && - "RNGestureHandlerButtonWrapper received more than one grandchild"); - - const auto clonedChild = directChild->clone({}); - - const auto childWithProtectedAccess = - std::static_pointer_cast( - clonedChild); - childWithProtectedAccess->traits_.unset(ShadowNodeTraits::ForceFlattenView); - - replaceChild(*directChild, clonedChild); - - const auto grandChild = clonedChild->getChildren()[0]; - const auto clonedGrandChild = grandChild->clone({}); - clonedChild->replaceChild(*grandChild, clonedGrandChild); -} - -void RNGestureHandlerButtonWrapperShadowNode::appendChild( - const std::shared_ptr &child) { - YogaLayoutableShadowNode::appendChild(child); - prepareChildren(); -} - -void RNGestureHandlerButtonWrapperShadowNode::layout( - LayoutContext layoutContext) { - react_native_assert(getChildren().size() == 1); - react_native_assert(getChildren()[0]->getChildren().size() == 1); - - auto child = std::static_pointer_cast( - getChildren()[0]); - auto grandChild = std::static_pointer_cast( - child->getChildren()[0]); - - auto gradChildWithProtectedAccess = - std::static_pointer_cast( - grandChild); - - auto shouldSkipCustomLayout = - !gradChildWithProtectedAccess->yogaNode_.getHasNewLayout(); - - YogaLayoutableShadowNode::layout(layoutContext); - - child->ensureUnsealed(); - grandChild->ensureUnsealed(); - - auto mutableChild = std::const_pointer_cast(child); - auto mutableGrandChild = - std::const_pointer_cast(grandChild); - - // The grand child node did not have its layout changed, we can reuse previous - // values - if (shouldSkipCustomLayout) { - react_native_assert(previousGrandChildLayoutMetrics_.has_value()); - setLayoutMetrics(previousGrandChildLayoutMetrics_.value()); - - auto metricsNoOrigin = previousGrandChildLayoutMetrics_.value(); - metricsNoOrigin.frame.origin = Point{}; - - mutableChild->setLayoutMetrics(metricsNoOrigin); - mutableGrandChild->setLayoutMetrics(metricsNoOrigin); - return; - } - - auto metrics = grandChild->getLayoutMetrics(); - previousGrandChildLayoutMetrics_ = metrics; - - setLayoutMetrics(metrics); - - auto metricsNoOrigin = grandChild->getLayoutMetrics(); - metricsNoOrigin.frame.origin = Point{}; - - mutableChild->setLayoutMetrics(metricsNoOrigin); - mutableGrandChild->setLayoutMetrics(metricsNoOrigin); -} - -} // namespace facebook::react diff --git a/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperShadowNode.h b/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperShadowNode.h deleted file mode 100644 index f04021c054..0000000000 --- a/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperShadowNode.h +++ /dev/null @@ -1,56 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include "RNGestureHandlerButtonWrapperState.h" - -namespace facebook::react { - -JSI_EXPORT extern const char RNGestureHandlerButtonWrapperComponentName[]; - -/* - * `ShadowNode` for component. - */ -class RNGestureHandlerButtonWrapperShadowNode final - : public ConcreteViewShadowNode< - RNGestureHandlerButtonWrapperComponentName, - RNGestureHandlerButtonWrapperProps, - RNGestureHandlerButtonWrapperEventEmitter, - RNGestureHandlerButtonWrapperState> { - public: - RNGestureHandlerButtonWrapperShadowNode( - const ShadowNodeFragment &fragment, - const ShadowNodeFamily::Shared &family, - ShadowNodeTraits traits) - : ConcreteViewShadowNode(fragment, family, traits) { - initialize(); - } - - RNGestureHandlerButtonWrapperShadowNode( - const ShadowNode &sourceShadowNode, - const ShadowNodeFragment &fragment) - : ConcreteViewShadowNode(sourceShadowNode, fragment) { - const auto &sourceWrapperNode = - static_cast( - sourceShadowNode); - previousGrandChildLayoutMetrics_ = - sourceWrapperNode.previousGrandChildLayoutMetrics_; - - initialize(); - } - - void layout(LayoutContext layoutContext) override; - void appendChild(const std::shared_ptr &child) override; - - private: - void initialize(); - void prepareChildren(); - - std::optional previousGrandChildLayoutMetrics_; -}; - -} // namespace facebook::react diff --git a/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperState.h b/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperState.h deleted file mode 100644 index 7d9fa242e0..0000000000 --- a/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerButtonWrapperState.h +++ /dev/null @@ -1,32 +0,0 @@ -/** - * This code was generated by - * [react-native-codegen](https://www.npmjs.com/package/react-native-codegen). - * - * Do not edit this file as changes may cause incorrect behavior and will be - * lost once the code is regenerated. - * - * @generated by codegen project: GenerateStateH.js - */ -#pragma once - -#ifdef ANDROID -#include -#endif - -namespace facebook::react { - -class RNGestureHandlerButtonWrapperState { - public: - RNGestureHandlerButtonWrapperState() = default; - -#ifdef ANDROID - RNGestureHandlerButtonWrapperState( - RNGestureHandlerButtonWrapperState const &previousState, - folly::dynamic data){}; - folly::dynamic getDynamic() const { - return {}; - }; -#endif -}; - -} // namespace facebook::react diff --git a/packages/react-native-gesture-handler/src/components/GestureButtons.tsx b/packages/react-native-gesture-handler/src/components/GestureButtons.tsx index 2832752605..238e2ff671 100644 --- a/packages/react-native-gesture-handler/src/components/GestureButtons.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureButtons.tsx @@ -19,12 +19,13 @@ import type { LegacyBorderlessButtonProps, LegacyRawButtonProps, } from './GestureButtonsProps'; +import type { HostComponent } from 'react-native'; /** * @deprecated use `RawButton` instead */ export const LegacyRawButton = createNativeWrapper( - GestureHandlerButton, + GestureHandlerButton as unknown as HostComponent, { shouldCancelWhenOutside: false, shouldActivateOnStart: false, diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 30ea4c0404..7e13963721 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -4,17 +4,10 @@ import { HostComponent, LayoutChangeEvent, StyleProp, - StyleSheet, - View, ViewProps, ViewStyle, } from 'react-native'; import RNGestureHandlerButtonNativeComponent from '../specs/RNGestureHandlerButtonNativeComponent'; -import RNGestureHandlerButtonWrapperNativeComponent from '../specs/RNGestureHandlerButtonWrapperNativeComponent'; -import { useMemo } from 'react'; - -export const ButtonComponent = - RNGestureHandlerButtonNativeComponent as HostComponent; export interface ButtonProps extends ViewProps, AccessibilityProps { children?: React.ReactNode; @@ -144,150 +137,7 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { testOnly_onLongPress?: Function | null | undefined; } -export default function GestureHandlerButton({ style, ...rest }: ButtonProps) { - const flattenedStyle = useMemo(() => StyleSheet.flatten(style), [style]); - - const { - // Layout properties - display, - width, - height, - minWidth, - maxWidth, - minHeight, - maxHeight, - flex, - flexGrow, - flexShrink, - flexBasis, - flexDirection, - flexWrap, - justifyContent, - alignItems, - alignContent, - alignSelf, - aspectRatio, - gap, - rowGap, - columnGap, - margin, - marginTop, - marginBottom, - marginLeft, - marginRight, - marginVertical, - marginHorizontal, - marginStart, - marginEnd, - padding, - paddingTop, - paddingBottom, - paddingLeft, - paddingRight, - paddingVertical, - paddingHorizontal, - paddingStart, - paddingEnd, - position, - top, - right, - bottom, - left, - start, - end, - overflow, - - // Visual properties - ...restStyle - } = flattenedStyle; - - // Layout styles for ButtonComponent - const layoutStyle = useMemo( - () => ({ - display, - width, - height, - minWidth, - maxWidth, - minHeight, - maxHeight, - flex, - flexGrow, - flexShrink, - flexBasis, - flexDirection, - flexWrap, - justifyContent, - alignItems, - alignContent, - alignSelf, - aspectRatio, - gap, - rowGap, - columnGap, - margin, - marginTop, - marginBottom, - marginLeft, - marginRight, - marginVertical, - marginHorizontal, - marginStart, - marginEnd, - padding, - paddingTop, - paddingBottom, - paddingLeft, - paddingRight, - paddingVertical, - paddingHorizontal, - paddingStart, - paddingEnd, - position, - top, - right, - bottom, - left, - start, - end, - overflow, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [flattenedStyle] - ); - - const { defaultOpacity, defaultScale } = rest; - - const buttonRestingStyle = useMemo( - (): ViewStyle => ({ - opacity: defaultOpacity, - transform: - defaultScale !== undefined ? [{ scale: defaultScale }] : undefined, - }), - [defaultOpacity, defaultScale] - ); - - return ( - - - - - - ); -} +export const ButtonComponent = + RNGestureHandlerButtonNativeComponent as HostComponent; -const styles = StyleSheet.create({ - contents: { - display: 'contents', - }, - overflowHidden: { - overflow: 'hidden', - }, -}); +export default ButtonComponent; diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonWrapperNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonWrapperNativeComponent.ts deleted file mode 100644 index d84e610aaa..0000000000 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonWrapperNativeComponent.ts +++ /dev/null @@ -1,11 +0,0 @@ -import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; -import type { ViewProps } from 'react-native'; - -interface NativeProps extends ViewProps {} - -export default codegenNativeComponent( - 'RNGestureHandlerButtonWrapper', - { - interfaceOnly: true, - } -); From feeed9a5a39a2e85f6aece3e09c0fd511d37977f Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 27 Mar 2026 15:15:36 +0100 Subject: [PATCH 02/15] Handle missing styles in button --- .../RNGestureHandlerButtonViewManager.kt | 391 +++++++++++------- .../apple/RNGestureHandlerButton.h | 10 +- .../apple/RNGestureHandlerButton.mm | 74 +++- .../RNGestureHandlerButtonComponentView.mm | 14 + .../RNGestureHandlerButtonNativeComponent.ts | 38 +- 5 files changed, 368 insertions(+), 159 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 058ef9e1a7..42278b2a39 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -7,12 +7,9 @@ import android.annotation.SuppressLint import android.annotation.TargetApi import android.content.Context import android.content.res.ColorStateList +import android.graphics.Canvas import android.graphics.Color -import android.graphics.DashPathEffect -import android.graphics.Paint -import android.graphics.PathEffect import android.graphics.drawable.Drawable -import android.graphics.drawable.LayerDrawable import android.graphics.drawable.PaintDrawable import android.graphics.drawable.RippleDrawable import android.graphics.drawable.ShapeDrawable @@ -28,6 +25,9 @@ import androidx.core.view.children import androidx.interpolator.view.animation.LinearOutSlowInInterpolator import com.facebook.react.R import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.BackgroundStyleApplicator +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.PointerEvents import com.facebook.react.uimanager.ReactPointerEventsView @@ -36,6 +36,9 @@ import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.uimanager.ViewProps import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.style.BorderRadiusProp +import com.facebook.react.uimanager.style.BorderStyle +import com.facebook.react.uimanager.style.LogicalEdge import com.facebook.react.viewmanagers.RNGestureHandlerButtonManagerDelegate import com.facebook.react.viewmanagers.RNGestureHandlerButtonManagerInterface import com.swmansion.gesturehandler.core.NativeViewGestureHandler @@ -63,7 +66,7 @@ class RNGestureHandlerButtonViewManager : @ReactProp(name = "backgroundColor") override fun setBackgroundColor(view: ButtonViewGroup, backgroundColor: Int) { - view.setBackgroundColor(backgroundColor) + BackgroundStyleApplicator.setBackgroundColor(view, backgroundColor) } @ReactProp(name = "borderless") @@ -76,44 +79,178 @@ class RNGestureHandlerButtonViewManager : view.isEnabled = enabled } + @ReactProp(name = "borderWidth") + override fun setBorderWidth(view: ButtonViewGroup, borderWidth: Float) { + BackgroundStyleApplicator.setBorderWidth(view, LogicalEdge.ALL, borderWidth) + } + + @ReactProp(name = "borderLeftWidth") + override fun setBorderLeftWidth(view: ButtonViewGroup, value: Float) { + BackgroundStyleApplicator.setBorderWidth(view, LogicalEdge.LEFT, value) + } + + @ReactProp(name = "borderRightWidth") + override fun setBorderRightWidth(view: ButtonViewGroup, value: Float) { + BackgroundStyleApplicator.setBorderWidth(view, LogicalEdge.RIGHT, value) + } + + @ReactProp(name = "borderTopWidth") + override fun setBorderTopWidth(view: ButtonViewGroup, value: Float) { + BackgroundStyleApplicator.setBorderWidth(view, LogicalEdge.TOP, value) + } + + @ReactProp(name = "borderBottomWidth") + override fun setBorderBottomWidth(view: ButtonViewGroup, value: Float) { + BackgroundStyleApplicator.setBorderWidth(view, LogicalEdge.BOTTOM, value) + } + + @ReactProp(name = "borderStartWidth") + override fun setBorderStartWidth(view: ButtonViewGroup, value: Float) { + BackgroundStyleApplicator.setBorderWidth(view, LogicalEdge.START, value) + } + + @ReactProp(name = "borderEndWidth") + override fun setBorderEndWidth(view: ButtonViewGroup, value: Float) { + BackgroundStyleApplicator.setBorderWidth(view, LogicalEdge.END, value) + } + + @ReactProp(name = "borderColor") + override fun setBorderColor(view: ButtonViewGroup, borderColor: Int?) { + BackgroundStyleApplicator.setBorderColor(view, LogicalEdge.ALL, borderColor) + } + + @ReactProp(name = "borderLeftColor") + override fun setBorderLeftColor(view: ButtonViewGroup, value: Int?) { + BackgroundStyleApplicator.setBorderColor(view, LogicalEdge.LEFT, value) + } + + @ReactProp(name = "borderRightColor") + override fun setBorderRightColor(view: ButtonViewGroup, value: Int?) { + BackgroundStyleApplicator.setBorderColor(view, LogicalEdge.RIGHT, value) + } + + @ReactProp(name = "borderTopColor") + override fun setBorderTopColor(view: ButtonViewGroup, value: Int?) { + BackgroundStyleApplicator.setBorderColor(view, LogicalEdge.TOP, value) + } + + @ReactProp(name = "borderBottomColor") + override fun setBorderBottomColor(view: ButtonViewGroup, value: Int?) { + BackgroundStyleApplicator.setBorderColor(view, LogicalEdge.BOTTOM, value) + } + + @ReactProp(name = "borderStartColor") + override fun setBorderStartColor(view: ButtonViewGroup, value: Int?) { + BackgroundStyleApplicator.setBorderColor(view, LogicalEdge.START, value) + } + + @ReactProp(name = "borderEndColor") + override fun setBorderEndColor(view: ButtonViewGroup, value: Int?) { + BackgroundStyleApplicator.setBorderColor(view, LogicalEdge.END, value) + } + + @ReactProp(name = "borderBlockColor") + override fun setBorderBlockColor(view: ButtonViewGroup, value: Int?) { + BackgroundStyleApplicator.setBorderColor(view, LogicalEdge.BLOCK, value) + } + + @ReactProp(name = "borderBlockEndColor") + override fun setBorderBlockEndColor(view: ButtonViewGroup, value: Int?) { + BackgroundStyleApplicator.setBorderColor(view, LogicalEdge.BLOCK_END, value) + } + + @ReactProp(name = "borderBlockStartColor") + override fun setBorderBlockStartColor(view: ButtonViewGroup, value: Int?) { + BackgroundStyleApplicator.setBorderColor(view, LogicalEdge.BLOCK_START, value) + } + + @ReactProp(name = "borderStyle") + override fun setBorderStyle(view: ButtonViewGroup, borderStyle: String?) { + val parsed = if (borderStyle == null) null else BorderStyle.fromString(borderStyle) + BackgroundStyleApplicator.setBorderStyle(view, parsed) + } + + @ReactProp(name = ViewProps.OVERFLOW) + override fun setOverflow(view: ButtonViewGroup, overflow: String?) { + view.setOverflow(overflow) + } + + private fun setBorderRadiusInternal( + view: ButtonViewGroup, + prop: BorderRadiusProp, + value: Float, + cornerIndex: Int = -2, + ) { + val lp = if (value.isNaN()) null else LengthPercentage(value, LengthPercentageType.POINT) + BackgroundStyleApplicator.setBorderRadius(view, prop, lp) + if (cornerIndex >= -1) { + view.updateCornerRadius(cornerIndex, if (value.isNaN()) 0f else value) + } + } + @ReactProp(name = ViewProps.BORDER_RADIUS) override fun setBorderRadius(view: ButtonViewGroup, borderRadius: Float) { - view.borderRadius = borderRadius + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_RADIUS, borderRadius, -1) } @ReactProp(name = "borderTopLeftRadius") - override fun setBorderTopLeftRadius(view: ButtonViewGroup, borderTopLeftRadius: Float) { - view.borderTopLeftRadius = borderTopLeftRadius + override fun setBorderTopLeftRadius(view: ButtonViewGroup, value: Float) { + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, value, 0) } @ReactProp(name = "borderTopRightRadius") - override fun setBorderTopRightRadius(view: ButtonViewGroup, borderTopRightRadius: Float) { - view.borderTopRightRadius = borderTopRightRadius + override fun setBorderTopRightRadius(view: ButtonViewGroup, value: Float) { + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_RIGHT_RADIUS, value, 1) + } + + @ReactProp(name = "borderBottomRightRadius") + override fun setBorderBottomRightRadius(view: ButtonViewGroup, value: Float) { + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_RIGHT_RADIUS, value, 2) } @ReactProp(name = "borderBottomLeftRadius") - override fun setBorderBottomLeftRadius(view: ButtonViewGroup, borderBottomLeftRadius: Float) { - view.borderBottomLeftRadius = borderBottomLeftRadius + override fun setBorderBottomLeftRadius(view: ButtonViewGroup, value: Float) { + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_LEFT_RADIUS, value, 3) } - @ReactProp(name = "borderBottomRightRadius") - override fun setBorderBottomRightRadius(view: ButtonViewGroup, borderBottomRightRadius: Float) { - view.borderBottomRightRadius = borderBottomRightRadius + @ReactProp(name = "borderTopStartRadius") + override fun setBorderTopStartRadius(view: ButtonViewGroup, value: Float) { + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_START_RADIUS, value) } - @ReactProp(name = "borderWidth") - override fun setBorderWidth(view: ButtonViewGroup, borderWidth: Float) { - view.borderWidth = borderWidth + @ReactProp(name = "borderTopEndRadius") + override fun setBorderTopEndRadius(view: ButtonViewGroup, value: Float) { + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_END_RADIUS, value) } - @ReactProp(name = "borderColor") - override fun setBorderColor(view: ButtonViewGroup, borderColor: Int?) { - view.borderColor = borderColor + @ReactProp(name = "borderBottomStartRadius") + override fun setBorderBottomStartRadius(view: ButtonViewGroup, value: Float) { + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_START_RADIUS, value) } - @ReactProp(name = "borderStyle") - override fun setBorderStyle(view: ButtonViewGroup, borderStyle: String?) { - view.borderStyle = borderStyle + @ReactProp(name = "borderBottomEndRadius") + override fun setBorderBottomEndRadius(view: ButtonViewGroup, value: Float) { + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_END_RADIUS, value) + } + + @ReactProp(name = "borderEndEndRadius") + override fun setBorderEndEndRadius(view: ButtonViewGroup, value: Float) { + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_END_END_RADIUS, value) + } + + @ReactProp(name = "borderEndStartRadius") + override fun setBorderEndStartRadius(view: ButtonViewGroup, value: Float) { + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_END_START_RADIUS, value) + } + + @ReactProp(name = "borderStartEndRadius") + override fun setBorderStartEndRadius(view: ButtonViewGroup, value: Float) { + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_START_END_RADIUS, value) + } + + @ReactProp(name = "borderStartStartRadius") + override fun setBorderStartStartRadius(view: ButtonViewGroup, value: Float) { + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_START_START_RADIUS, value) } @ReactProp(name = "rippleColor") @@ -214,45 +351,6 @@ class RNGestureHandlerButtonViewManager : field = useForeground } var useBorderlessDrawable = false - var borderRadius = 0f - set(radius) = withBackgroundUpdate { - field = radius * resources.displayMetrics.density - } - var borderTopLeftRadius = 0f - set(radius) = withBackgroundUpdate { - field = radius * resources.displayMetrics.density - } - var borderTopRightRadius = 0f - set(radius) = withBackgroundUpdate { - field = radius * resources.displayMetrics.density - } - var borderBottomLeftRadius = 0f - set(radius) = withBackgroundUpdate { - field = radius * resources.displayMetrics.density - } - var borderBottomRightRadius = 0f - set(radius) = withBackgroundUpdate { - field = radius * resources.displayMetrics.density - } - var borderWidth = 0f - set(width) = withBackgroundUpdate { - field = width * resources.displayMetrics.density - } - var borderColor: Int? = null - set(color) = withBackgroundUpdate { - field = color - } - var borderStyle: String? = "solid" - set(style) = withBackgroundUpdate { - field = style - } - - private val hasBorderRadii: Boolean - get() = borderRadius != 0f || - borderTopLeftRadius != 0f || - borderTopRightRadius != 0f || - borderBottomLeftRadius != 0f || - borderBottomRightRadius != 0f var exclusive = true var animationDuration: Int = 100 @@ -272,7 +370,12 @@ class RNGestureHandlerButtonViewManager : override var pointerEvents: PointerEvents = PointerEvents.AUTO - private var buttonBackgroundColor = Color.TRANSPARENT + // Border radii tracked for ripple mask and underlay clipping. + // BackgroundStyleApplicator handles the visual border rendering, + // but ripple/underlay drawables need the resolved radii separately. + private var generalBorderRadius = 0f + private var cornerBorderRadii: FloatArray? = null // [TL, TR, BR, BL] in dp + private var needBackgroundUpdate = false private var lastEventTime = -1L private var lastAction = -1 @@ -280,6 +383,10 @@ class RNGestureHandlerButtonViewManager : private var currentAnimator: AnimatorSet? = null private var underlayDrawable: PaintDrawable? = null + // When non-null the ripple is drawn in dispatchDraw (above background, below children). + // When null the ripple lives on the foreground drawable instead. + private var selectableDrawable: Drawable? = null + var isTouched = false init { @@ -296,30 +403,50 @@ class RNGestureHandlerButtonViewManager : needBackgroundUpdate = true } - private fun buildBorderRadii(): FloatArray { - // duplicate radius for each corner, as setCornerRadii expects X radius and Y radius for each - return floatArrayOf( - borderTopLeftRadius, - borderTopLeftRadius, - borderTopRightRadius, - borderTopRightRadius, - borderBottomRightRadius, - borderBottomRightRadius, - borderBottomLeftRadius, - borderBottomLeftRadius, - ) - .map { if (it != 0f) it else borderRadius } - .toFloatArray() + fun updateCornerRadius(corner: Int, radiusDp: Float) { + if (corner == -1) { + generalBorderRadius = radiusDp + } else { + if (cornerBorderRadii == null) { + cornerBorderRadii = FloatArray(4) + } + cornerBorderRadii!![corner] = radiusDp + } + needBackgroundUpdate = true } - private fun buildBorderStyle(): PathEffect? = when (borderStyle) { - "dotted" -> DashPathEffect(floatArrayOf(borderWidth, borderWidth, borderWidth, borderWidth), 0f) - "dashed" -> DashPathEffect(floatArrayOf(borderWidth * 3, borderWidth * 3, borderWidth * 3, borderWidth * 3), 0f) - else -> null + private fun buildCornerRadii(): FloatArray? { + val corners = cornerBorderRadii + val general = generalBorderRadius + if (general == 0f && corners == null) return null + + val density = resources.displayMetrics.density + val tl = (corners?.get(0)?.takeIf { it != 0f } ?: general) * density + val tr = (corners?.get(1)?.takeIf { it != 0f } ?: general) * density + val br = (corners?.get(2)?.takeIf { it != 0f } ?: general) * density + val bl = (corners?.get(3)?.takeIf { it != 0f } ?: general) * density + + if (tl == 0f && tr == 0f && br == 0f && bl == 0f) return null + + return floatArrayOf(tl, tl, tr, tr, br, br, bl, bl) + } + + fun setOverflow(overflow: String?) { + when (overflow) { + "hidden" -> { + clipChildren = true + clipToPadding = true + } + "scroll", "visible" -> { + clipChildren = false + clipToPadding = false + } + } + invalidate() } - override fun setBackgroundColor(color: Int) = withBackgroundUpdate { - buttonBackgroundColor = color + override fun setBackgroundColor(color: Int) { + BackgroundStyleApplicator.setBackgroundColor(this, color) } override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) { @@ -439,92 +566,65 @@ class RNGestureHandlerButtonViewManager : private fun createUnderlayDrawable(): PaintDrawable { val drawable = PaintDrawable(underlayColor ?: Color.BLACK) - if (hasBorderRadii) { - drawable.setCornerRadii(buildBorderRadii()) - } + buildCornerRadii()?.let { drawable.setCornerRadii(it) } drawable.alpha = (defaultUnderlayOpacity * 255).toInt() return drawable } - private fun updateBackgroundColor( - backgroundColor: Int, - underlay: Drawable, - borderDrawable: Drawable, - selectable: Drawable?, - ) { - val colorDrawable = PaintDrawable(backgroundColor) - - if (hasBorderRadii) { - colorDrawable.setCornerRadii(buildBorderRadii()) - } - - val layerDrawable = LayerDrawable( - if (selectable != null) { - arrayOf(colorDrawable, underlay, selectable, borderDrawable) - } else { - arrayOf(colorDrawable, underlay, borderDrawable) - }, - ) - background = layerDrawable - } - fun updateBackground() { if (!needBackgroundUpdate) { return } needBackgroundUpdate = false - if (buttonBackgroundColor == Color.TRANSPARENT) { - // reset background - background = null - } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - // reset foreground foreground = null } val selectable = createSelectableDrawable() - val borderDrawable = createBorderDrawable() val underlay = createUnderlayDrawable() underlayDrawable = underlay + // Set this view as callback so ObjectAnimator alpha changes trigger redraws. + underlay.callback = this - if (hasBorderRadii && selectable is RippleDrawable) { - val mask = PaintDrawable(Color.WHITE) - mask.setCornerRadii(buildBorderRadii()) - selectable.setDrawableByLayerId(android.R.id.mask, mask) - } - - if (useDrawableOnForeground && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (useDrawableOnForeground && selectable != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // Explicit foreground mode — View natively forwards state/hotspot. foreground = selectable - if (buttonBackgroundColor != Color.TRANSPARENT) { - updateBackgroundColor(buttonBackgroundColor, underlay, borderDrawable, null) - } - } else if (buttonBackgroundColor == Color.TRANSPARENT && rippleColor == null) { - background = LayerDrawable(arrayOf(underlay, selectable, borderDrawable)) + selectableDrawable = null } else { - updateBackgroundColor(buttonBackgroundColor, underlay, borderDrawable, selectable) + // Default — draw ripple in dispatchDraw above background, below children. + // State and hotspot are forwarded manually. + selectableDrawable = selectable + selectable?.callback = this } applyStartAnimationState() } - private fun createBorderDrawable(): Drawable { - val borderDrawable = PaintDrawable(Color.TRANSPARENT) - - if (hasBorderRadii) { - borderDrawable.setCornerRadii(buildBorderRadii()) + // Draw the underlay and ripple between background and children. + override fun dispatchDraw(canvas: Canvas) { + underlayDrawable?.let { + it.setBounds(0, 0, width, height) + it.draw(canvas) } + selectableDrawable?.let { + it.setBounds(0, 0, width, height) + it.draw(canvas) + } + super.dispatchDraw(canvas) + } - if (borderWidth > 0f) { - borderDrawable.paint.apply { - style = Paint.Style.STROKE - strokeWidth = borderWidth - color = borderColor ?: Color.BLACK - pathEffect = buildBorderStyle() + override fun verifyDrawable(who: Drawable): Boolean = + super.verifyDrawable(who) || who == underlayDrawable || who == selectableDrawable + + override fun drawableStateChanged() { + super.drawableStateChanged() + // Forward pressed/enabled state to the ripple when it's drawn manually. + selectableDrawable?.let { + if (it.isStateful) { + it.setState(drawableState) } } - - return borderDrawable } private fun createSelectableDrawable(): Drawable? { @@ -555,6 +655,13 @@ class RNGestureHandlerButtonViewManager : drawable.radius = PixelUtil.toPixelFromDIP(rippleRadius.toFloat()).toInt() } + val radii = buildCornerRadii() + if (radii != null && drawable is RippleDrawable && !useBorderlessDrawable) { + val mask = PaintDrawable(Color.WHITE) + mask.setCornerRadii(radii) + drawable.setDrawableByLayerId(android.R.id.mask, mask) + } + return drawable } @@ -565,6 +672,8 @@ class RNGestureHandlerButtonViewManager : override fun drawableHotspotChanged(x: Float, y: Float) { if (touchResponder == null || touchResponder === this) { super.drawableHotspotChanged(x, y) + // Forward hotspot to the ripple when it's drawn manually. + selectableDrawable?.setHotspot(x, y) } } diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index e421de01b8..d7d6d060e2 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h @@ -24,7 +24,6 @@ * Insets used when hit testing inside this view. */ @property (nonatomic, assign) UIEdgeInsets hitTestEdgeInsets; -@property (nonatomic, assign) CGFloat borderRadius; @property (nonatomic) BOOL userEnabled; @property (nonatomic, assign) RNGestureHandlerPointerEvents pointerEvents; @@ -49,6 +48,15 @@ */ - (void)applyStartAnimationState; +/** + * Updates the underlay layer's corner radii. Handles both uniform + * (simple cornerRadius) and per-corner (CAShapeLayer mask) cases. + */ +- (void)setUnderlayCornerRadiiWithTopLeft:(CGFloat)topLeft + topRight:(CGFloat)topRight + bottomLeft:(CGFloat)bottomLeft + bottomRight:(CGFloat)bottomRight; + #if TARGET_OS_OSX - (void)mountChildComponentView:(RNGHUIView *)childComponentView index:(NSInteger)index; - (void)unmountChildComponentView:(RNGHUIView *)childComponentView index:(NSInteger)index; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index c433e64bec..a97973f202 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -41,6 +41,7 @@ */ @implementation RNGestureHandlerButton { CALayer *_underlayLayer; + CGFloat _underlayCornerRadii[4]; // TL, TR, BL, BR } - (void)commonInit @@ -104,6 +105,7 @@ - (void)layoutSubviews [super layoutSubviews]; _underlayLayer.frame = self.bounds; [self.layer insertSublayer:_underlayLayer atIndex:0]; + [self applyUnderlayCornerRadii]; } #else - (void)layout @@ -111,6 +113,7 @@ - (void)layout [super layout]; _underlayLayer.frame = self.bounds; [self.layer insertSublayer:_underlayLayer atIndex:0]; + [self applyUnderlayCornerRadii]; } #endif @@ -307,28 +310,71 @@ - (RNGHUIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event return inner; } -- (void)setBorderRadius:(CGFloat)radius +- (void)setUnderlayCornerRadiiWithTopLeft:(CGFloat)topLeft + topRight:(CGFloat)topRight + bottomLeft:(CGFloat)bottomLeft + bottomRight:(CGFloat)bottomRight { - if (_borderRadius == radius) { + _underlayCornerRadii[0] = topLeft; + _underlayCornerRadii[1] = topRight; + _underlayCornerRadii[2] = bottomLeft; + _underlayCornerRadii[3] = bottomRight; + [self applyUnderlayCornerRadii]; +} + +- (void)applyUnderlayCornerRadii +{ + CGFloat tl = _underlayCornerRadii[0]; + CGFloat tr = _underlayCornerRadii[1]; + CGFloat bl = _underlayCornerRadii[2]; + CGFloat br = _underlayCornerRadii[3]; + + if (tl == 0 && tr == 0 && bl == 0 && br == 0) { + _underlayLayer.cornerRadius = 0; + _underlayLayer.mask = nil; return; } - _borderRadius = radius; - [self.layer setNeedsDisplay]; -} + if (tl == tr && tr == bl && bl == br) { + // Uniform — simple cornerRadius is enough + _underlayLayer.cornerRadius = tl; + _underlayLayer.mask = nil; + return; + } -- (void)displayLayer:(CALayer *)layer -{ - if (CGSizeEqualToSize(layer.bounds.size, CGSizeZero)) { + // Non-uniform — build a CAShapeLayer mask + _underlayLayer.cornerRadius = 0; + CGRect rect = _underlayLayer.bounds; + if (CGRectIsEmpty(rect)) { return; } - const CGFloat radius = MAX(0, _borderRadius); - const CGSize size = self.bounds.size; - const CGFloat scaleFactor = RCTZeroIfNaN(MIN(1, size.width / (2 * radius))); - const CGFloat currentBorderRadius = radius * scaleFactor; - layer.cornerRadius = currentBorderRadius; - _underlayLayer.cornerRadius = currentBorderRadius; + CGFloat w = rect.size.width; + CGFloat h = rect.size.height; + + UIBezierPath *path = [UIBezierPath new]; + [path moveToPoint:CGPointMake(tl, 0)]; + [path addLineToPoint:CGPointMake(w - tr, 0)]; + if (tr > 0) { + [path addArcWithCenter:CGPointMake(w - tr, tr) radius:tr startAngle:-M_PI_2 endAngle:0 clockwise:YES]; + } + [path addLineToPoint:CGPointMake(w, h - br)]; + if (br > 0) { + [path addArcWithCenter:CGPointMake(w - br, h - br) radius:br startAngle:0 endAngle:M_PI_2 clockwise:YES]; + } + [path addLineToPoint:CGPointMake(bl, h)]; + if (bl > 0) { + [path addArcWithCenter:CGPointMake(bl, h - bl) radius:bl startAngle:M_PI_2 endAngle:M_PI clockwise:YES]; + } + [path addLineToPoint:CGPointMake(0, tl)]; + if (tl > 0) { + [path addArcWithCenter:CGPointMake(tl, tl) radius:tl startAngle:M_PI endAngle:3 * M_PI_2 clockwise:YES]; + } + [path closePath]; + + CAShapeLayer *mask = [CAShapeLayer new]; + mask.path = path.CGPath; + _underlayLayer.mask = mask; } - (NSString *)accessibilityLabel diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index 616b129d87..fb3b22f693 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -7,6 +7,7 @@ #import #import #import +#import #import #import "RNGestureHandlerButton.h" @@ -109,6 +110,19 @@ - (void)updateLayoutMetrics:(const facebook::react::LayoutMetrics &)layoutMetric [_buttonView updateLayoutMetrics:buttonMetrics oldLayoutMetrics:oldbuttonMetrics]; } +- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask +{ + [super finalizeUpdates:updateMask]; + + // Resolve per-corner border radii from props and forward to the button + // so its underlay CALayer gets the matching shape. + const auto borderMetrics = _props->resolveBorderMetrics(_layoutMetrics); + [_buttonView setUnderlayCornerRadiiWithTopLeft:borderMetrics.borderRadii.topLeft.horizontal + topRight:borderMetrics.borderRadii.topRight.horizontal + bottomLeft:borderMetrics.borderRadii.bottomLeft.horizontal + bottomRight:borderMetrics.borderRadii.bottomRight.horizontal]; +} + #pragma mark - RCTComponentViewProtocol + (ComponentDescriptorProvider)componentDescriptorProvider diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index 47d37f37cf..c82f019d55 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -15,9 +15,6 @@ interface NativeProps extends ViewProps { rippleColor?: ColorValue; rippleRadius?: Int32; touchSoundDisabled?: WithDefault; - borderWidth?: Float; - borderColor?: ColorValue; - borderStyle?: WithDefault; pointerEvents?: WithDefault< 'box-none' | 'none' | 'box-only' | 'auto', 'auto' @@ -30,6 +27,41 @@ interface NativeProps extends ViewProps { defaultScale?: WithDefault; defaultUnderlayOpacity?: WithDefault; underlayColor?: ColorValue; + + // Border style + borderWidth?: Float; + borderColor?: ColorValue; + borderStyle?: WithDefault; + overflow?: WithDefault; + + // Border width per-edge + borderLeftWidth?: Float; + borderRightWidth?: Float; + borderTopWidth?: Float; + borderBottomWidth?: Float; + borderStartWidth?: Float; + borderEndWidth?: Float; + + // Border color per-edge + borderLeftColor?: ColorValue; + borderRightColor?: ColorValue; + borderTopColor?: ColorValue; + borderBottomColor?: ColorValue; + borderStartColor?: ColorValue; + borderEndColor?: ColorValue; + borderBlockColor?: ColorValue; + borderBlockEndColor?: ColorValue; + borderBlockStartColor?: ColorValue; + + // Border radius — logical variants beyond what ViewProps provides + borderTopStartRadius?: Float; + borderTopEndRadius?: Float; + borderBottomStartRadius?: Float; + borderBottomEndRadius?: Float; + borderEndEndRadius?: Float; + borderEndStartRadius?: Float; + borderStartEndRadius?: Float; + borderStartStartRadius?: Float; } export default codegenNativeComponent('RNGestureHandlerButton'); From 77edca6cb487c0f15b4a1483036f8efd64612d18 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 30 Mar 2026 10:45:59 +0200 Subject: [PATCH 03/15] Don't use 0 as "not set" --- .../react/RNGestureHandlerButtonViewManager.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 42278b2a39..a39338dd44 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -409,6 +409,7 @@ class RNGestureHandlerButtonViewManager : } else { if (cornerBorderRadii == null) { cornerBorderRadii = FloatArray(4) + cornerBorderRadii!!.fill(-1f) } cornerBorderRadii!![corner] = radiusDp } @@ -421,10 +422,10 @@ class RNGestureHandlerButtonViewManager : if (general == 0f && corners == null) return null val density = resources.displayMetrics.density - val tl = (corners?.get(0)?.takeIf { it != 0f } ?: general) * density - val tr = (corners?.get(1)?.takeIf { it != 0f } ?: general) * density - val br = (corners?.get(2)?.takeIf { it != 0f } ?: general) * density - val bl = (corners?.get(3)?.takeIf { it != 0f } ?: general) * density + val tl = (corners?.get(0)?.takeIf { it != -1f } ?: general) * density + val tr = (corners?.get(1)?.takeIf { it != -1f } ?: general) * density + val br = (corners?.get(2)?.takeIf { it != -1f } ?: general) * density + val bl = (corners?.get(3)?.takeIf { it != -1f } ?: general) * density if (tl == 0f && tr == 0f && br == 0f && bl == 0f) return null From e7205188888457a5d4209302aad5be9cca13c2fb Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 30 Mar 2026 10:48:07 +0200 Subject: [PATCH 04/15] Add catch-all to overflow setter --- .../gesturehandler/react/RNGestureHandlerButtonViewManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index a39338dd44..a34253c53d 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -438,7 +438,7 @@ class RNGestureHandlerButtonViewManager : clipChildren = true clipToPadding = true } - "scroll", "visible" -> { + else -> { clipChildren = false clipToPadding = false } From dc65f05190579434c469cfadbc90d861e81aadfa Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 30 Mar 2026 11:13:21 +0200 Subject: [PATCH 05/15] Clamp values --- .../apple/RNGestureHandlerButton.mm | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index a97973f202..f5136e7247 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -324,10 +324,33 @@ - (void)setUnderlayCornerRadiiWithTopLeft:(CGFloat)topLeft - (void)applyUnderlayCornerRadii { - CGFloat tl = _underlayCornerRadii[0]; - CGFloat tr = _underlayCornerRadii[1]; - CGFloat bl = _underlayCornerRadii[2]; - CGFloat br = _underlayCornerRadii[3]; + CGRect rect = _underlayLayer.bounds; + CGFloat w = rect.size.width; + CGFloat h = rect.size.height; + + CGFloat tl = MAX(0, _underlayCornerRadii[0]); + CGFloat tr = MAX(0, _underlayCornerRadii[1]); + CGFloat bl = MAX(0, _underlayCornerRadii[2]); + CGFloat br = MAX(0, _underlayCornerRadii[3]); + + // CSS border-radius proportional scaling: if adjacent radii on any edge + // exceed that edge's length, scale all radii down by the same factor. + CGFloat f = 1.0; + if (tl + tr > 0) + f = MIN(f, w / (tl + tr)); + if (bl + br > 0) + f = MIN(f, w / (bl + br)); + if (tl + bl > 0) + f = MIN(f, h / (tl + bl)); + if (tr + br > 0) + f = MIN(f, h / (tr + br)); + + if (f < 1.0) { + tl *= f; + tr *= f; + bl *= f; + br *= f; + } if (tl == 0 && tr == 0 && bl == 0 && br == 0) { _underlayLayer.cornerRadius = 0; @@ -344,14 +367,10 @@ - (void)applyUnderlayCornerRadii // Non-uniform — build a CAShapeLayer mask _underlayLayer.cornerRadius = 0; - CGRect rect = _underlayLayer.bounds; if (CGRectIsEmpty(rect)) { return; } - CGFloat w = rect.size.width; - CGFloat h = rect.size.height; - UIBezierPath *path = [UIBezierPath new]; [path moveToPoint:CGPointMake(tl, 0)]; [path addLineToPoint:CGPointMake(w - tr, 0)]; From b8f3c255571491c531e3e3406e6f950535d393c0 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 30 Mar 2026 11:30:45 +0200 Subject: [PATCH 06/15] Update prop priority and defaults --- .../RNGestureHandlerButtonViewManager.kt | 90 ++++++++++++++----- .../RNGestureHandlerButtonNativeComponent.ts | 18 ++-- 2 files changed, 80 insertions(+), 28 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index a34253c53d..b4d6adbefb 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -180,77 +180,83 @@ class RNGestureHandlerButtonViewManager : prop: BorderRadiusProp, value: Float, cornerIndex: Int = -2, + logicalCorner: Int = -1, ) { - val lp = if (value.isNaN()) null else LengthPercentage(value, LengthPercentageType.POINT) + // NaN = unset (physical props), negative = unset (codegen default for logical props) + val isUnset = value.isNaN() || value < 0f + val lp = if (isUnset) null else LengthPercentage(value, LengthPercentageType.POINT) BackgroundStyleApplicator.setBorderRadius(view, prop, lp) if (cornerIndex >= -1) { - view.updateCornerRadius(cornerIndex, if (value.isNaN()) 0f else value) + view.updateCornerRadius(cornerIndex, if (isUnset) -1f else value) + } + if (logicalCorner >= 0) { + view.updateLogicalCornerRadius(logicalCorner, if (isUnset) -1f else value) } } @ReactProp(name = ViewProps.BORDER_RADIUS) override fun setBorderRadius(view: ButtonViewGroup, borderRadius: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_RADIUS, borderRadius, -1) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_RADIUS, borderRadius, cornerIndex = -1) } @ReactProp(name = "borderTopLeftRadius") override fun setBorderTopLeftRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, value, 0) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, value, cornerIndex = 0) } @ReactProp(name = "borderTopRightRadius") override fun setBorderTopRightRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_RIGHT_RADIUS, value, 1) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_RIGHT_RADIUS, value, cornerIndex = 1) } @ReactProp(name = "borderBottomRightRadius") override fun setBorderBottomRightRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_RIGHT_RADIUS, value, 2) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_RIGHT_RADIUS, value, cornerIndex = 2) } @ReactProp(name = "borderBottomLeftRadius") override fun setBorderBottomLeftRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_LEFT_RADIUS, value, 3) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_LEFT_RADIUS, value, cornerIndex = 3) } @ReactProp(name = "borderTopStartRadius") override fun setBorderTopStartRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_START_RADIUS, value) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_START_RADIUS, value, logicalCorner = 0) } @ReactProp(name = "borderTopEndRadius") override fun setBorderTopEndRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_END_RADIUS, value) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_END_RADIUS, value, logicalCorner = 1) } @ReactProp(name = "borderBottomStartRadius") override fun setBorderBottomStartRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_START_RADIUS, value) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_START_RADIUS, value, logicalCorner = 3) } @ReactProp(name = "borderBottomEndRadius") override fun setBorderBottomEndRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_END_RADIUS, value) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_END_RADIUS, value, logicalCorner = 2) } @ReactProp(name = "borderEndEndRadius") override fun setBorderEndEndRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_END_END_RADIUS, value) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_END_END_RADIUS, value, logicalCorner = 2) } @ReactProp(name = "borderEndStartRadius") override fun setBorderEndStartRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_END_START_RADIUS, value) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_END_START_RADIUS, value, logicalCorner = 3) } @ReactProp(name = "borderStartEndRadius") override fun setBorderStartEndRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_START_END_RADIUS, value) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_START_END_RADIUS, value, logicalCorner = 1) } @ReactProp(name = "borderStartStartRadius") override fun setBorderStartStartRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_START_START_RADIUS, value) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_START_START_RADIUS, value, logicalCorner = 0) } @ReactProp(name = "rippleColor") @@ -375,6 +381,7 @@ class RNGestureHandlerButtonViewManager : // but ripple/underlay drawables need the resolved radii separately. private var generalBorderRadius = 0f private var cornerBorderRadii: FloatArray? = null // [TL, TR, BR, BL] in dp + private var logicalBorderRadii: FloatArray? = null // [topStart, topEnd, bottomEnd, bottomStart] in dp private var needBackgroundUpdate = false private var lastEventTime = -1L @@ -416,16 +423,51 @@ class RNGestureHandlerButtonViewManager : needBackgroundUpdate = true } + fun updateLogicalCornerRadius(logicalCorner: Int, radiusDp: Float) { + if (logicalBorderRadii == null) { + logicalBorderRadii = FloatArray(4) + logicalBorderRadii!!.fill(-1f) + } + logicalBorderRadii!![logicalCorner] = radiusDp + needBackgroundUpdate = true + } + private fun buildCornerRadii(): FloatArray? { val corners = cornerBorderRadii + val logical = logicalBorderRadii val general = generalBorderRadius - if (general == 0f && corners == null) return null + if (general == 0f && corners == null && logical == null) return null + val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL val density = resources.displayMetrics.density - val tl = (corners?.get(0)?.takeIf { it != -1f } ?: general) * density - val tr = (corners?.get(1)?.takeIf { it != -1f } ?: general) * density - val br = (corners?.get(2)?.takeIf { it != -1f } ?: general) * density - val bl = (corners?.get(3)?.takeIf { it != -1f } ?: general) * density + + // Resolve logical → physical based on layout direction. + // logical: [topStart(0), topEnd(1), bottomEnd(2), bottomStart(3)] + // physical: [TL(0), TR(1), BR(2), BL(3)] + fun resolveLogical(physicalCorner: Int): Float { + if (logical == null) return -1f + return when (physicalCorner) { + 0 -> if (isRtl) logical[1] else logical[0] + 1 -> if (isRtl) logical[0] else logical[1] + 2 -> if (isRtl) logical[3] else logical[2] + 3 -> if (isRtl) logical[2] else logical[3] + else -> -1f + } + } + + // Priority: logical corner > physical corner > general + fun resolve(physicalCorner: Int): Float { + val logicalVal = resolveLogical(physicalCorner).takeIf { it != -1f } + if (logicalVal != null) return logicalVal * density + val physical = corners?.get(physicalCorner)?.takeIf { it != -1f } + if (physical != null) return physical * density + return general * density + } + + val tl = resolve(0) + val tr = resolve(1) + val br = resolve(2) + val bl = resolve(3) if (tl == 0f && tr == 0f && br == 0f && bl == 0f) return null @@ -666,6 +708,14 @@ class RNGestureHandlerButtonViewManager : return drawable } + override fun onRtlPropertiesChanged(layoutDirection: Int) { + super.onRtlPropertiesChanged(layoutDirection) + if (logicalBorderRadii != null) { + needBackgroundUpdate = true + updateBackground() + } + } + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { // No-op } diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index c82f019d55..6ab8bcaca7 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -54,14 +54,16 @@ interface NativeProps extends ViewProps { borderBlockStartColor?: ColorValue; // Border radius — logical variants beyond what ViewProps provides - borderTopStartRadius?: Float; - borderTopEndRadius?: Float; - borderBottomStartRadius?: Float; - borderBottomEndRadius?: Float; - borderEndEndRadius?: Float; - borderEndStartRadius?: Float; - borderStartEndRadius?: Float; - borderStartStartRadius?: Float; + // WithDefault -1 so the codegen sends -1 (our "unset" sentinel) instead of 0 + // when the prop is absent, letting physical / general radii take effect. + borderTopStartRadius?: WithDefault; + borderTopEndRadius?: WithDefault; + borderBottomStartRadius?: WithDefault; + borderBottomEndRadius?: WithDefault; + borderEndEndRadius?: WithDefault; + borderEndStartRadius?: WithDefault; + borderStartEndRadius?: WithDefault; + borderStartStartRadius?: WithDefault; } export default codegenNativeComponent('RNGestureHandlerButton'); From dbbd3986b801486bd775726d31efcd38dd7ef00f Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 30 Mar 2026 11:56:22 +0200 Subject: [PATCH 07/15] Clip Android to paddingbox --- .../RNGestureHandlerButtonViewManager.kt | 55 +++++++++++++++---- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index b4d6adbefb..e5424bb5fa 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -464,13 +464,31 @@ class RNGestureHandlerButtonViewManager : return general * density } - val tl = resolve(0) - val tr = resolve(1) - val br = resolve(2) - val bl = resolve(3) + var tl = resolve(0) + var tr = resolve(1) + var br = resolve(2) + var bl = resolve(3) if (tl == 0f && tr == 0f && br == 0f && bl == 0f) return null + // CSS border-radius proportional scaling: if adjacent radii on any edge + // exceed that edge's length, scale all radii down by the same factor. + val w = width.toFloat() + val h = height.toFloat() + if (w > 0f && h > 0f) { + var f = 1f + if (tl + tr > 0f) f = minOf(f, w / (tl + tr)) + if (bl + br > 0f) f = minOf(f, w / (bl + br)) + if (tl + bl > 0f) f = minOf(f, h / (tl + bl)) + if (tr + br > 0f) f = minOf(f, h / (tr + br)) + if (f < 1f) { + tl *= f + tr *= f + br *= f + bl *= f + } + } + return floatArrayOf(tl, tl, tr, tr, br, br, bl, bl) } @@ -607,9 +625,9 @@ class RNGestureHandlerButtonViewManager : animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity) } - private fun createUnderlayDrawable(): PaintDrawable { + private fun createUnderlayDrawable(radii: FloatArray?): PaintDrawable { val drawable = PaintDrawable(underlayColor ?: Color.BLACK) - buildCornerRadii()?.let { drawable.setCornerRadii(it) } + radii?.let { drawable.setCornerRadii(it) } drawable.alpha = (defaultUnderlayOpacity * 255).toInt() return drawable } @@ -624,8 +642,10 @@ class RNGestureHandlerButtonViewManager : foreground = null } - val selectable = createSelectableDrawable() - val underlay = createUnderlayDrawable() + val radii = buildCornerRadii() + + val selectable = createSelectableDrawable(radii) + val underlay = createUnderlayDrawable(radii) underlayDrawable = underlay // Set this view as callback so ObjectAnimator alpha changes trigger redraws. underlay.callback = this @@ -645,7 +665,14 @@ class RNGestureHandlerButtonViewManager : } // Draw the underlay and ripple between background and children. + // Clip to BackgroundStyleApplicator's padding box so the overlay + // never extends beyond the view's resolved border-radius shape. override fun dispatchDraw(canvas: Canvas) { + val hasOverlay = underlayDrawable != null || selectableDrawable != null + if (hasOverlay) { + canvas.save() + BackgroundStyleApplicator.clipToPaddingBox(this, canvas) + } underlayDrawable?.let { it.setBounds(0, 0, width, height) it.draw(canvas) @@ -654,6 +681,9 @@ class RNGestureHandlerButtonViewManager : it.setBounds(0, 0, width, height) it.draw(canvas) } + if (hasOverlay) { + canvas.restore() + } super.dispatchDraw(canvas) } @@ -670,7 +700,7 @@ class RNGestureHandlerButtonViewManager : } } - private fun createSelectableDrawable(): Drawable? { + private fun createSelectableDrawable(radii: FloatArray?): Drawable? { // don't create ripple drawable at all when it's not supposed to be visible if (rippleColor == Color.TRANSPARENT) { return null @@ -698,7 +728,6 @@ class RNGestureHandlerButtonViewManager : drawable.radius = PixelUtil.toPixelFromDIP(rippleRadius.toFloat()).toInt() } - val radii = buildCornerRadii() if (radii != null && drawable is RippleDrawable && !useBorderlessDrawable) { val mask = PaintDrawable(Color.WHITE) mask.setCornerRadii(radii) @@ -708,6 +737,12 @@ class RNGestureHandlerButtonViewManager : return drawable } + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + needBackgroundUpdate = true + updateBackground() + } + override fun onRtlPropertiesChanged(layoutDirection: Int) { super.onRtlPropertiesChanged(layoutDirection) if (logicalBorderRadii != null) { From 533ad6c6733222f712988bacbc4ea6586a2a4c41 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 30 Mar 2026 12:37:52 +0200 Subject: [PATCH 08/15] Clip iOS to paddingbox --- .../apple/RNGestureHandlerButton.h | 23 ++- .../apple/RNGestureHandlerButton.mm | 148 ++++++++++++------ .../RNGestureHandlerButtonComponentView.mm | 16 +- 3 files changed, 132 insertions(+), 55 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index d7d6d060e2..2672d0b37a 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h @@ -49,13 +49,24 @@ - (void)applyStartAnimationState; /** - * Updates the underlay layer's corner radii. Handles both uniform - * (simple cornerRadius) and per-corner (CAShapeLayer mask) cases. + * Updates the underlay layer's corner radii with separate horizontal/vertical + * components per corner, supporting elliptical inner corners when border widths + * are uneven. Handles both uniform and per-corner (CAShapeLayer mask) cases. */ -- (void)setUnderlayCornerRadiiWithTopLeft:(CGFloat)topLeft - topRight:(CGFloat)topRight - bottomLeft:(CGFloat)bottomLeft - bottomRight:(CGFloat)bottomRight; +- (void)setUnderlayCornerRadiiWithTopLeftHorizontal:(CGFloat)topLeftHorizontal + topLeftVertical:(CGFloat)topLeftVertical + topRightHorizontal:(CGFloat)topRightHorizontal + topRightVertical:(CGFloat)topRightVertical + bottomLeftHorizontal:(CGFloat)bottomLeftHorizontal + bottomLeftVertical:(CGFloat)bottomLeftVertical + bottomRightHorizontal:(CGFloat)bottomRightHorizontal + bottomRightVertical:(CGFloat)bottomRightVertical; + +/** + * Sets the border insets so the underlay is clipped to the padding box + * (inside the border). + */ +- (void)setUnderlayBorderInsetsWithTop:(CGFloat)top right:(CGFloat)right bottom:(CGFloat)bottom left:(CGFloat)left; #if TARGET_OS_OSX - (void)mountChildComponentView:(RNGHUIView *)childComponentView index:(NSInteger)index; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index f5136e7247..0d26b6fdb2 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -41,7 +41,8 @@ */ @implementation RNGestureHandlerButton { CALayer *_underlayLayer; - CGFloat _underlayCornerRadii[4]; // TL, TR, BL, BR + CGFloat _underlayCornerRadii[8]; // [tlH, tlV, trH, trV, blH, blV, brH, brV] outer radii in points + UIEdgeInsets _underlayBorderInsets; // border widths for padding-box inset } - (void)commonInit @@ -103,7 +104,7 @@ - (void)setUnderlayColor:(RNGHColor *)underlayColor - (void)layoutSubviews { [super layoutSubviews]; - _underlayLayer.frame = self.bounds; + _underlayLayer.frame = UIEdgeInsetsInsetRect(self.bounds, _underlayBorderInsets); [self.layer insertSublayer:_underlayLayer atIndex:0]; [self applyUnderlayCornerRadii]; } @@ -111,7 +112,12 @@ - (void)layoutSubviews - (void)layout { [super layout]; - _underlayLayer.frame = self.bounds; + CGRect bounds = self.bounds; + _underlayLayer.frame = CGRectMake( + bounds.origin.x + _underlayBorderInsets.left, + bounds.origin.y + _underlayBorderInsets.top, + MAX(0, bounds.size.width - _underlayBorderInsets.left - _underlayBorderInsets.right), + MAX(0, bounds.size.height - _underlayBorderInsets.top - _underlayBorderInsets.bottom)); [self.layer insertSublayer:_underlayLayer atIndex:0]; [self applyUnderlayCornerRadii]; } @@ -310,85 +316,137 @@ - (RNGHUIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event return inner; } -- (void)setUnderlayCornerRadiiWithTopLeft:(CGFloat)topLeft - topRight:(CGFloat)topRight - bottomLeft:(CGFloat)bottomLeft - bottomRight:(CGFloat)bottomRight +- (void)setUnderlayCornerRadiiWithTopLeftHorizontal:(CGFloat)topLeftHorizontal + topLeftVertical:(CGFloat)topLeftVertical + topRightHorizontal:(CGFloat)topRightHorizontal + topRightVertical:(CGFloat)topRightVertical + bottomLeftHorizontal:(CGFloat)bottomLeftHorizontal + bottomLeftVertical:(CGFloat)bottomLeftVertical + bottomRightHorizontal:(CGFloat)bottomRightHorizontal + bottomRightVertical:(CGFloat)bottomRightVertical { - _underlayCornerRadii[0] = topLeft; - _underlayCornerRadii[1] = topRight; - _underlayCornerRadii[2] = bottomLeft; - _underlayCornerRadii[3] = bottomRight; + _underlayCornerRadii[0] = topLeftHorizontal; + _underlayCornerRadii[1] = topLeftVertical; + _underlayCornerRadii[2] = topRightHorizontal; + _underlayCornerRadii[3] = topRightVertical; + _underlayCornerRadii[4] = bottomLeftHorizontal; + _underlayCornerRadii[5] = bottomLeftVertical; + _underlayCornerRadii[6] = bottomRightHorizontal; + _underlayCornerRadii[7] = bottomRightVertical; [self applyUnderlayCornerRadii]; } +- (void)setUnderlayBorderInsetsWithTop:(CGFloat)top right:(CGFloat)right bottom:(CGFloat)bottom left:(CGFloat)left +{ + _underlayBorderInsets = UIEdgeInsetsMake(top, left, bottom, right); +#if !TARGET_OS_OSX + [self setNeedsLayout]; +#else + [self setNeedsLayout:YES]; +#endif +} + - (void)applyUnderlayCornerRadii { CGRect rect = _underlayLayer.bounds; CGFloat w = rect.size.width; CGFloat h = rect.size.height; - CGFloat tl = MAX(0, _underlayCornerRadii[0]); - CGFloat tr = MAX(0, _underlayCornerRadii[1]); - CGFloat bl = MAX(0, _underlayCornerRadii[2]); - CGFloat br = MAX(0, _underlayCornerRadii[3]); + // Inner border radii: outer radius minus adjacent border width per axis, clamped to 0. + CGFloat topLeftHorizontal = MAX(0, _underlayCornerRadii[0] - _underlayBorderInsets.left); + CGFloat topLeftVertical = MAX(0, _underlayCornerRadii[1] - _underlayBorderInsets.top); + CGFloat topRightHorizontal = MAX(0, _underlayCornerRadii[2] - _underlayBorderInsets.right); + CGFloat topRightVertical = MAX(0, _underlayCornerRadii[3] - _underlayBorderInsets.top); + CGFloat bottomLeftHorizontal = MAX(0, _underlayCornerRadii[4] - _underlayBorderInsets.left); + CGFloat bottomLeftVertical = MAX(0, _underlayCornerRadii[5] - _underlayBorderInsets.bottom); + CGFloat bottomRightHorizontal = MAX(0, _underlayCornerRadii[6] - _underlayBorderInsets.right); + CGFloat bottomRightVertical = MAX(0, _underlayCornerRadii[7] - _underlayBorderInsets.bottom); // CSS border-radius proportional scaling: if adjacent radii on any edge // exceed that edge's length, scale all radii down by the same factor. CGFloat f = 1.0; - if (tl + tr > 0) - f = MIN(f, w / (tl + tr)); - if (bl + br > 0) - f = MIN(f, w / (bl + br)); - if (tl + bl > 0) - f = MIN(f, h / (tl + bl)); - if (tr + br > 0) - f = MIN(f, h / (tr + br)); + if (topLeftHorizontal + topRightHorizontal > 0) + f = MIN(f, w / (topLeftHorizontal + topRightHorizontal)); + if (bottomLeftHorizontal + bottomRightHorizontal > 0) + f = MIN(f, w / (bottomLeftHorizontal + bottomRightHorizontal)); + if (topLeftVertical + bottomLeftVertical > 0) + f = MIN(f, h / (topLeftVertical + bottomLeftVertical)); + if (topRightVertical + bottomRightVertical > 0) + f = MIN(f, h / (topRightVertical + bottomRightVertical)); if (f < 1.0) { - tl *= f; - tr *= f; - bl *= f; - br *= f; + topLeftHorizontal *= f; + topLeftVertical *= f; + topRightHorizontal *= f; + topRightVertical *= f; + bottomLeftHorizontal *= f; + bottomLeftVertical *= f; + bottomRightHorizontal *= f; + bottomRightVertical *= f; } - if (tl == 0 && tr == 0 && bl == 0 && br == 0) { + if (topLeftHorizontal == 0 && topLeftVertical == 0 && topRightHorizontal == 0 && topRightVertical == 0 && + bottomLeftHorizontal == 0 && bottomLeftVertical == 0 && bottomRightHorizontal == 0 && bottomRightVertical == 0) { _underlayLayer.cornerRadius = 0; _underlayLayer.mask = nil; return; } - if (tl == tr && tr == bl && bl == br) { - // Uniform — simple cornerRadius is enough - _underlayLayer.cornerRadius = tl; + // Uniform circular — simple cornerRadius is enough + if (topLeftHorizontal == topLeftVertical && topRightHorizontal == topRightVertical && + bottomLeftHorizontal == bottomLeftVertical && bottomRightHorizontal == bottomRightVertical && + topLeftHorizontal == topRightHorizontal && topRightHorizontal == bottomLeftHorizontal && + bottomLeftHorizontal == bottomRightHorizontal) { + _underlayLayer.cornerRadius = topLeftHorizontal; _underlayLayer.mask = nil; return; } - // Non-uniform — build a CAShapeLayer mask + // Non-uniform or elliptical — build a CAShapeLayer mask using cubic + // Bezier approximation for quarter-ellipse arcs (kappa ≈ 0.5523). _underlayLayer.cornerRadius = 0; if (CGRectIsEmpty(rect)) { return; } + const CGFloat k = 0.5522847498; UIBezierPath *path = [UIBezierPath new]; - [path moveToPoint:CGPointMake(tl, 0)]; - [path addLineToPoint:CGPointMake(w - tr, 0)]; - if (tr > 0) { - [path addArcWithCenter:CGPointMake(w - tr, tr) radius:tr startAngle:-M_PI_2 endAngle:0 clockwise:YES]; + + // Start after top-left corner + [path moveToPoint:CGPointMake(topLeftHorizontal, 0)]; + + // Top edge -> top-right corner + [path addLineToPoint:CGPointMake(w - topRightHorizontal, 0)]; + if (topRightHorizontal > 0 || topRightVertical > 0) { + [path addCurveToPoint:CGPointMake(w, topRightVertical) + controlPoint1:CGPointMake(w - topRightHorizontal + topRightHorizontal * k, 0) + controlPoint2:CGPointMake(w, topRightVertical - topRightVertical * k)]; } - [path addLineToPoint:CGPointMake(w, h - br)]; - if (br > 0) { - [path addArcWithCenter:CGPointMake(w - br, h - br) radius:br startAngle:0 endAngle:M_PI_2 clockwise:YES]; + + // Right edge -> bottom-right corner + [path addLineToPoint:CGPointMake(w, h - bottomRightVertical)]; + if (bottomRightHorizontal > 0 || bottomRightVertical > 0) { + [path addCurveToPoint:CGPointMake(w - bottomRightHorizontal, h) + controlPoint1:CGPointMake(w, h - bottomRightVertical + bottomRightVertical * k) + controlPoint2:CGPointMake(w - bottomRightHorizontal + bottomRightHorizontal * k, h)]; } - [path addLineToPoint:CGPointMake(bl, h)]; - if (bl > 0) { - [path addArcWithCenter:CGPointMake(bl, h - bl) radius:bl startAngle:M_PI_2 endAngle:M_PI clockwise:YES]; + + // Bottom edge -> bottom-left corner + [path addLineToPoint:CGPointMake(bottomLeftHorizontal, h)]; + if (bottomLeftHorizontal > 0 || bottomLeftVertical > 0) { + [path addCurveToPoint:CGPointMake(0, h - bottomLeftVertical) + controlPoint1:CGPointMake(bottomLeftHorizontal - bottomLeftHorizontal * k, h) + controlPoint2:CGPointMake(0, h - bottomLeftVertical + bottomLeftVertical * k)]; } - [path addLineToPoint:CGPointMake(0, tl)]; - if (tl > 0) { - [path addArcWithCenter:CGPointMake(tl, tl) radius:tl startAngle:M_PI endAngle:3 * M_PI_2 clockwise:YES]; + + // Left edge -> top-left corner + [path addLineToPoint:CGPointMake(0, topLeftVertical)]; + if (topLeftHorizontal > 0 || topLeftVertical > 0) { + [path addCurveToPoint:CGPointMake(topLeftHorizontal, 0) + controlPoint1:CGPointMake(0, topLeftVertical - topLeftVertical * k) + controlPoint2:CGPointMake(topLeftHorizontal - topLeftHorizontal * k, 0)]; } + [path closePath]; CAShapeLayer *mask = [CAShapeLayer new]; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index fb3b22f693..e840cfb296 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -117,10 +117,18 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask // Resolve per-corner border radii from props and forward to the button // so its underlay CALayer gets the matching shape. const auto borderMetrics = _props->resolveBorderMetrics(_layoutMetrics); - [_buttonView setUnderlayCornerRadiiWithTopLeft:borderMetrics.borderRadii.topLeft.horizontal - topRight:borderMetrics.borderRadii.topRight.horizontal - bottomLeft:borderMetrics.borderRadii.bottomLeft.horizontal - bottomRight:borderMetrics.borderRadii.bottomRight.horizontal]; + [_buttonView setUnderlayCornerRadiiWithTopLeftHorizontal:borderMetrics.borderRadii.topLeft.horizontal + topLeftVertical:borderMetrics.borderRadii.topLeft.vertical + topRightHorizontal:borderMetrics.borderRadii.topRight.horizontal + topRightVertical:borderMetrics.borderRadii.topRight.vertical + bottomLeftHorizontal:borderMetrics.borderRadii.bottomLeft.horizontal + bottomLeftVertical:borderMetrics.borderRadii.bottomLeft.vertical + bottomRightHorizontal:borderMetrics.borderRadii.bottomRight.horizontal + bottomRightVertical:borderMetrics.borderRadii.bottomRight.vertical]; + [_buttonView setUnderlayBorderInsetsWithTop:borderMetrics.borderWidths.top + right:borderMetrics.borderWidths.right + bottom:borderMetrics.borderWidths.bottom + left:borderMetrics.borderWidths.left]; } #pragma mark - RCTComponentViewProtocol From b8f3f411aab07ccd7f576c1c256a26dba3747536 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 30 Mar 2026 12:53:13 +0200 Subject: [PATCH 09/15] Clip underlay on macos --- .../apple/RNGestureHandlerButton.mm | 237 +++++++++--------- 1 file changed, 121 insertions(+), 116 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 0d26b6fdb2..58ae58a0a7 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -250,102 +250,6 @@ - (void)handleAnimatePressOut } } -#if TARGET_OS_OSX -- (void)mouseDown:(NSEvent *)event -{ - [self handleAnimatePressIn]; - [super mouseDown:event]; -} - -- (void)mouseUp:(NSEvent *)event -{ - [self handleAnimatePressOut]; - [super mouseUp:event]; -} - -- (void)mouseDragged:(NSEvent *)event -{ - NSPoint locationInWindow = [event locationInWindow]; - NSPoint locationInView = [self convertPoint:locationInWindow fromView:nil]; - - if (!NSPointInRect(locationInView, self.bounds)) { - [self handleAnimatePressOut]; - } -} -#endif - -#if !TARGET_OS_OSX -- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event -{ - if (UIEdgeInsetsEqualToEdgeInsets(self.hitTestEdgeInsets, UIEdgeInsetsZero)) { - return [super pointInside:point withEvent:event]; - } - CGRect hitFrame = UIEdgeInsetsInsetRect(self.bounds, self.hitTestEdgeInsets); - return CGRectContainsPoint(hitFrame, point); -} - -- (RNGHUIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event -{ - RNGestureHandlerPointerEvents pointerEvents = _pointerEvents; - - if (pointerEvents == RNGestureHandlerPointerEventsNone) { - return nil; - } - - if (pointerEvents == RNGestureHandlerPointerEventsBoxNone) { - for (UIView *subview in [self.subviews reverseObjectEnumerator]) { - if (!subview.isHidden && subview.alpha > 0) { - CGPoint convertedPoint = [subview convertPoint:point fromView:self]; - UIView *hitView = [subview hitTest:convertedPoint withEvent:event]; - if (hitView != nil && [self shouldHandleTouch:hitView]) { - return hitView; - } - } - } - return nil; - } - - if (pointerEvents == RNGestureHandlerPointerEventsBoxOnly) { - return [self pointInside:point withEvent:event] ? self : nil; - } - - RNGHUIView *inner = [super hitTest:point withEvent:event]; - while (inner && ![self shouldHandleTouch:inner]) { - inner = inner.superview; - } - return inner; -} - -- (void)setUnderlayCornerRadiiWithTopLeftHorizontal:(CGFloat)topLeftHorizontal - topLeftVertical:(CGFloat)topLeftVertical - topRightHorizontal:(CGFloat)topRightHorizontal - topRightVertical:(CGFloat)topRightVertical - bottomLeftHorizontal:(CGFloat)bottomLeftHorizontal - bottomLeftVertical:(CGFloat)bottomLeftVertical - bottomRightHorizontal:(CGFloat)bottomRightHorizontal - bottomRightVertical:(CGFloat)bottomRightVertical -{ - _underlayCornerRadii[0] = topLeftHorizontal; - _underlayCornerRadii[1] = topLeftVertical; - _underlayCornerRadii[2] = topRightHorizontal; - _underlayCornerRadii[3] = topRightVertical; - _underlayCornerRadii[4] = bottomLeftHorizontal; - _underlayCornerRadii[5] = bottomLeftVertical; - _underlayCornerRadii[6] = bottomRightHorizontal; - _underlayCornerRadii[7] = bottomRightVertical; - [self applyUnderlayCornerRadii]; -} - -- (void)setUnderlayBorderInsetsWithTop:(CGFloat)top right:(CGFloat)right bottom:(CGFloat)bottom left:(CGFloat)left -{ - _underlayBorderInsets = UIEdgeInsetsMake(top, left, bottom, right); -#if !TARGET_OS_OSX - [self setNeedsLayout]; -#else - [self setNeedsLayout:YES]; -#endif -} - - (void)applyUnderlayCornerRadii { CGRect rect = _underlayLayer.bounds; @@ -409,51 +313,152 @@ - (void)applyUnderlayCornerRadii return; } + CGMutablePathRef path = CGPathCreateMutable(); const CGFloat k = 0.5522847498; - UIBezierPath *path = [UIBezierPath new]; // Start after top-left corner - [path moveToPoint:CGPointMake(topLeftHorizontal, 0)]; + CGPathMoveToPoint(path, NULL, topLeftHorizontal, 0); // Top edge -> top-right corner - [path addLineToPoint:CGPointMake(w - topRightHorizontal, 0)]; + CGPathAddLineToPoint(path, NULL, w - topRightHorizontal, 0); if (topRightHorizontal > 0 || topRightVertical > 0) { - [path addCurveToPoint:CGPointMake(w, topRightVertical) - controlPoint1:CGPointMake(w - topRightHorizontal + topRightHorizontal * k, 0) - controlPoint2:CGPointMake(w, topRightVertical - topRightVertical * k)]; + CGPathAddCurveToPoint(path, NULL, + w - topRightHorizontal + topRightHorizontal * k, 0, + w, topRightVertical - topRightVertical * k, + w, topRightVertical); } // Right edge -> bottom-right corner - [path addLineToPoint:CGPointMake(w, h - bottomRightVertical)]; + CGPathAddLineToPoint(path, NULL, w, h - bottomRightVertical); if (bottomRightHorizontal > 0 || bottomRightVertical > 0) { - [path addCurveToPoint:CGPointMake(w - bottomRightHorizontal, h) - controlPoint1:CGPointMake(w, h - bottomRightVertical + bottomRightVertical * k) - controlPoint2:CGPointMake(w - bottomRightHorizontal + bottomRightHorizontal * k, h)]; + CGPathAddCurveToPoint(path, NULL, + w, h - bottomRightVertical + bottomRightVertical * k, + w - bottomRightHorizontal + bottomRightHorizontal * k, h, + w - bottomRightHorizontal, h); } // Bottom edge -> bottom-left corner - [path addLineToPoint:CGPointMake(bottomLeftHorizontal, h)]; + CGPathAddLineToPoint(path, NULL, bottomLeftHorizontal, h); if (bottomLeftHorizontal > 0 || bottomLeftVertical > 0) { - [path addCurveToPoint:CGPointMake(0, h - bottomLeftVertical) - controlPoint1:CGPointMake(bottomLeftHorizontal - bottomLeftHorizontal * k, h) - controlPoint2:CGPointMake(0, h - bottomLeftVertical + bottomLeftVertical * k)]; + CGPathAddCurveToPoint(path, NULL, + bottomLeftHorizontal - bottomLeftHorizontal * k, h, + 0, h - bottomLeftVertical + bottomLeftVertical * k, + 0, h - bottomLeftVertical); } // Left edge -> top-left corner - [path addLineToPoint:CGPointMake(0, topLeftVertical)]; + CGPathAddLineToPoint(path, NULL, 0, topLeftVertical); if (topLeftHorizontal > 0 || topLeftVertical > 0) { - [path addCurveToPoint:CGPointMake(topLeftHorizontal, 0) - controlPoint1:CGPointMake(0, topLeftVertical - topLeftVertical * k) - controlPoint2:CGPointMake(topLeftHorizontal - topLeftHorizontal * k, 0)]; + CGPathAddCurveToPoint(path, NULL, + 0, topLeftVertical - topLeftVertical * k, + topLeftHorizontal - topLeftHorizontal * k, 0, + topLeftHorizontal, 0); } - [path closePath]; + CGPathCloseSubpath(path); CAShapeLayer *mask = [CAShapeLayer new]; - mask.path = path.CGPath; + mask.path = path; + CGPathRelease(path); _underlayLayer.mask = mask; } +- (void)setUnderlayCornerRadiiWithTopLeftHorizontal:(CGFloat)topLeftHorizontal + topLeftVertical:(CGFloat)topLeftVertical + topRightHorizontal:(CGFloat)topRightHorizontal + topRightVertical:(CGFloat)topRightVertical + bottomLeftHorizontal:(CGFloat)bottomLeftHorizontal + bottomLeftVertical:(CGFloat)bottomLeftVertical + bottomRightHorizontal:(CGFloat)bottomRightHorizontal + bottomRightVertical:(CGFloat)bottomRightVertical +{ + _underlayCornerRadii[0] = topLeftHorizontal; + _underlayCornerRadii[1] = topLeftVertical; + _underlayCornerRadii[2] = topRightHorizontal; + _underlayCornerRadii[3] = topRightVertical; + _underlayCornerRadii[4] = bottomLeftHorizontal; + _underlayCornerRadii[5] = bottomLeftVertical; + _underlayCornerRadii[6] = bottomRightHorizontal; + _underlayCornerRadii[7] = bottomRightVertical; + [self applyUnderlayCornerRadii]; +} + +- (void)setUnderlayBorderInsetsWithTop:(CGFloat)top right:(CGFloat)right bottom:(CGFloat)bottom left:(CGFloat)left +{ + _underlayBorderInsets = UIEdgeInsetsMake(top, left, bottom, right); +#if !TARGET_OS_OSX + [self setNeedsLayout]; +#else + [self setNeedsLayout:YES]; +#endif +} + +#if TARGET_OS_OSX +- (void)mouseDown:(NSEvent *)event +{ + [self handleAnimatePressIn]; + [super mouseDown:event]; +} + +- (void)mouseUp:(NSEvent *)event +{ + [self handleAnimatePressOut]; + [super mouseUp:event]; +} + +- (void)mouseDragged:(NSEvent *)event +{ + NSPoint locationInWindow = [event locationInWindow]; + NSPoint locationInView = [self convertPoint:locationInWindow fromView:nil]; + + if (!NSPointInRect(locationInView, self.bounds)) { + [self handleAnimatePressOut]; + } +} +#endif + +#if !TARGET_OS_OSX +- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event +{ + if (UIEdgeInsetsEqualToEdgeInsets(self.hitTestEdgeInsets, UIEdgeInsetsZero)) { + return [super pointInside:point withEvent:event]; + } + CGRect hitFrame = UIEdgeInsetsInsetRect(self.bounds, self.hitTestEdgeInsets); + return CGRectContainsPoint(hitFrame, point); +} + +- (RNGHUIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event +{ + RNGestureHandlerPointerEvents pointerEvents = _pointerEvents; + + if (pointerEvents == RNGestureHandlerPointerEventsNone) { + return nil; + } + + if (pointerEvents == RNGestureHandlerPointerEventsBoxNone) { + for (UIView *subview in [self.subviews reverseObjectEnumerator]) { + if (!subview.isHidden && subview.alpha > 0) { + CGPoint convertedPoint = [subview convertPoint:point fromView:self]; + UIView *hitView = [subview hitTest:convertedPoint withEvent:event]; + if (hitView != nil && [self shouldHandleTouch:hitView]) { + return hitView; + } + } + } + return nil; + } + + if (pointerEvents == RNGestureHandlerPointerEventsBoxOnly) { + return [self pointInside:point withEvent:event] ? self : nil; + } + + RNGHUIView *inner = [super hitTest:point withEvent:event]; + while (inner && ![self shouldHandleTouch:inner]) { + inner = inner.superview; + } + return inner; +} + - (NSString *)accessibilityLabel { NSString *label = super.accessibilityLabel; From ad0881bde3d3190aaa1885ea32a647b6624b89aa Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 30 Mar 2026 13:25:20 +0200 Subject: [PATCH 10/15] Fix coordinates on macos --- .../apple/RNGestureHandlerButton.mm | 91 ++++++++++++++----- 1 file changed, 68 insertions(+), 23 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 58ae58a0a7..0d91a519fe 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -113,9 +113,11 @@ - (void)layout { [super layout]; CGRect bounds = self.bounds; + // macOS layer coordinate system has origin at bottom-left, + // so the y offset uses the bottom inset, not the top. _underlayLayer.frame = CGRectMake( bounds.origin.x + _underlayBorderInsets.left, - bounds.origin.y + _underlayBorderInsets.top, + bounds.origin.y + _underlayBorderInsets.bottom, MAX(0, bounds.size.width - _underlayBorderInsets.left - _underlayBorderInsets.right), MAX(0, bounds.size.height - _underlayBorderInsets.top - _underlayBorderInsets.bottom)); [self.layer insertSublayer:_underlayLayer atIndex:0]; @@ -256,15 +258,36 @@ - (void)applyUnderlayCornerRadii CGFloat w = rect.size.width; CGFloat h = rect.size.height; + // On macOS, CALayer origin is at the bottom-left (y increases upward), so + // path y=0 is the visual bottom. Swap top <-> bottom radii and border insets + // so the path corners map to the correct visual corners. +#if TARGET_OS_OSX + const CGFloat *outerTL = &_underlayCornerRadii[4]; // path top-left = visual bottom-left + const CGFloat *outerTR = &_underlayCornerRadii[6]; // path top-right = visual bottom-right + const CGFloat *outerBL = &_underlayCornerRadii[0]; // path bottom-left = visual top-left + const CGFloat *outerBR = &_underlayCornerRadii[2]; // path bottom-right = visual top-right + CGFloat borderTop = _underlayBorderInsets.bottom; + CGFloat borderBottom = _underlayBorderInsets.top; +#else + const CGFloat *outerTL = &_underlayCornerRadii[0]; + const CGFloat *outerTR = &_underlayCornerRadii[2]; + const CGFloat *outerBL = &_underlayCornerRadii[4]; + const CGFloat *outerBR = &_underlayCornerRadii[6]; + CGFloat borderTop = _underlayBorderInsets.top; + CGFloat borderBottom = _underlayBorderInsets.bottom; +#endif + CGFloat borderLeft = _underlayBorderInsets.left; + CGFloat borderRight = _underlayBorderInsets.right; + // Inner border radii: outer radius minus adjacent border width per axis, clamped to 0. - CGFloat topLeftHorizontal = MAX(0, _underlayCornerRadii[0] - _underlayBorderInsets.left); - CGFloat topLeftVertical = MAX(0, _underlayCornerRadii[1] - _underlayBorderInsets.top); - CGFloat topRightHorizontal = MAX(0, _underlayCornerRadii[2] - _underlayBorderInsets.right); - CGFloat topRightVertical = MAX(0, _underlayCornerRadii[3] - _underlayBorderInsets.top); - CGFloat bottomLeftHorizontal = MAX(0, _underlayCornerRadii[4] - _underlayBorderInsets.left); - CGFloat bottomLeftVertical = MAX(0, _underlayCornerRadii[5] - _underlayBorderInsets.bottom); - CGFloat bottomRightHorizontal = MAX(0, _underlayCornerRadii[6] - _underlayBorderInsets.right); - CGFloat bottomRightVertical = MAX(0, _underlayCornerRadii[7] - _underlayBorderInsets.bottom); + CGFloat topLeftHorizontal = MAX(0, outerTL[0] - borderLeft); + CGFloat topLeftVertical = MAX(0, outerTL[1] - borderTop); + CGFloat topRightHorizontal = MAX(0, outerTR[0] - borderRight); + CGFloat topRightVertical = MAX(0, outerTR[1] - borderTop); + CGFloat bottomLeftHorizontal = MAX(0, outerBL[0] - borderLeft); + CGFloat bottomLeftVertical = MAX(0, outerBL[1] - borderBottom); + CGFloat bottomRightHorizontal = MAX(0, outerBR[0] - borderRight); + CGFloat bottomRightVertical = MAX(0, outerBR[1] - borderBottom); // CSS border-radius proportional scaling: if adjacent radii on any edge // exceed that edge's length, scale all radii down by the same factor. @@ -289,6 +312,17 @@ - (void)applyUnderlayCornerRadii bottomRightVertical *= f; } + // Snap sub-pixel radii to 0 to avoid degenerate curves that cause + // anti-aliasing artifacts at what should be sharp corners. + if (topLeftHorizontal < 0.5) topLeftHorizontal = 0; + if (topLeftVertical < 0.5) topLeftVertical = 0; + if (topRightHorizontal < 0.5) topRightHorizontal = 0; + if (topRightVertical < 0.5) topRightVertical = 0; + if (bottomLeftHorizontal < 0.5) bottomLeftHorizontal = 0; + if (bottomLeftVertical < 0.5) bottomLeftVertical = 0; + if (bottomRightHorizontal < 0.5) bottomRightHorizontal = 0; + if (bottomRightVertical < 0.5) bottomRightVertical = 0; + if (topLeftHorizontal == 0 && topLeftVertical == 0 && topRightHorizontal == 0 && topRightVertical == 0 && bottomLeftHorizontal == 0 && bottomLeftVertical == 0 && bottomRightHorizontal == 0 && bottomRightVertical == 0) { _underlayLayer.cornerRadius = 0; @@ -315,45 +349,56 @@ - (void)applyUnderlayCornerRadii CGMutablePathRef path = CGPathCreateMutable(); const CGFloat k = 0.5522847498; + BOOL hasTL = topLeftHorizontal > 0 && topLeftVertical > 0; + BOOL hasTR = topRightHorizontal > 0 && topRightVertical > 0; + BOOL hasBR = bottomRightHorizontal > 0 && bottomRightVertical > 0; + BOOL hasBL = bottomLeftHorizontal > 0 && bottomLeftVertical > 0; - // Start after top-left corner - CGPathMoveToPoint(path, NULL, topLeftHorizontal, 0); + // Start at the top edge (after top-left corner if rounded) + CGPathMoveToPoint(path, NULL, hasTL ? topLeftHorizontal : 0, 0); - // Top edge -> top-right corner - CGPathAddLineToPoint(path, NULL, w - topRightHorizontal, 0); - if (topRightHorizontal > 0 || topRightVertical > 0) { + // Top edge → top-right corner + if (hasTR) { + CGPathAddLineToPoint(path, NULL, w - topRightHorizontal, 0); CGPathAddCurveToPoint(path, NULL, w - topRightHorizontal + topRightHorizontal * k, 0, w, topRightVertical - topRightVertical * k, w, topRightVertical); + } else { + CGPathAddLineToPoint(path, NULL, w, 0); } - // Right edge -> bottom-right corner - CGPathAddLineToPoint(path, NULL, w, h - bottomRightVertical); - if (bottomRightHorizontal > 0 || bottomRightVertical > 0) { + // Right edge → bottom-right corner + if (hasBR) { + CGPathAddLineToPoint(path, NULL, w, h - bottomRightVertical); CGPathAddCurveToPoint(path, NULL, w, h - bottomRightVertical + bottomRightVertical * k, w - bottomRightHorizontal + bottomRightHorizontal * k, h, w - bottomRightHorizontal, h); + } else { + CGPathAddLineToPoint(path, NULL, w, h); } - // Bottom edge -> bottom-left corner - CGPathAddLineToPoint(path, NULL, bottomLeftHorizontal, h); - if (bottomLeftHorizontal > 0 || bottomLeftVertical > 0) { + // Bottom edge → bottom-left corner + if (hasBL) { + CGPathAddLineToPoint(path, NULL, bottomLeftHorizontal, h); CGPathAddCurveToPoint(path, NULL, bottomLeftHorizontal - bottomLeftHorizontal * k, h, 0, h - bottomLeftVertical + bottomLeftVertical * k, 0, h - bottomLeftVertical); + } else { + CGPathAddLineToPoint(path, NULL, 0, h); } - // Left edge -> top-left corner - CGPathAddLineToPoint(path, NULL, 0, topLeftVertical); - if (topLeftHorizontal > 0 || topLeftVertical > 0) { + // Left edge → top-left corner + if (hasTL) { + CGPathAddLineToPoint(path, NULL, 0, topLeftVertical); CGPathAddCurveToPoint(path, NULL, 0, topLeftVertical - topLeftVertical * k, topLeftHorizontal - topLeftHorizontal * k, 0, topLeftHorizontal, 0); } + // closePath returns to the start point — (0,0) if no TL curve CGPathCloseSubpath(path); From c433764d5b8ca53396bba77c778fcd3989db3a90 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 30 Mar 2026 13:34:50 +0200 Subject: [PATCH 11/15] Flip coordinate space --- .../apple/RNGestureHandlerButton.mm | 36 ++++++------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 0d91a519fe..dcab972ab8 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -100,30 +100,28 @@ - (void)setUnderlayColor:(RNGHColor *)underlayColor _underlayLayer.backgroundColor = underlayColor.CGColor; } +#if TARGET_OS_OSX +// Flip the macOS coordinate system so y=0 is at the top, matching iOS +// and React Native's layout expectations. +- (BOOL)isFlipped +{ + return YES; +} +#endif + #if !TARGET_OS_OSX - (void)layoutSubviews { [super layoutSubviews]; - _underlayLayer.frame = UIEdgeInsetsInsetRect(self.bounds, _underlayBorderInsets); - [self.layer insertSublayer:_underlayLayer atIndex:0]; - [self applyUnderlayCornerRadii]; -} #else - (void)layout { [super layout]; - CGRect bounds = self.bounds; - // macOS layer coordinate system has origin at bottom-left, - // so the y offset uses the bottom inset, not the top. - _underlayLayer.frame = CGRectMake( - bounds.origin.x + _underlayBorderInsets.left, - bounds.origin.y + _underlayBorderInsets.bottom, - MAX(0, bounds.size.width - _underlayBorderInsets.left - _underlayBorderInsets.right), - MAX(0, bounds.size.height - _underlayBorderInsets.top - _underlayBorderInsets.bottom)); +#endif + _underlayLayer.frame = UIEdgeInsetsInsetRect(self.bounds, _underlayBorderInsets); [self.layer insertSublayer:_underlayLayer atIndex:0]; [self applyUnderlayCornerRadii]; } -#endif - (BOOL)shouldHandleTouch:(RNGHUIView *)view { @@ -258,24 +256,12 @@ - (void)applyUnderlayCornerRadii CGFloat w = rect.size.width; CGFloat h = rect.size.height; - // On macOS, CALayer origin is at the bottom-left (y increases upward), so - // path y=0 is the visual bottom. Swap top <-> bottom radii and border insets - // so the path corners map to the correct visual corners. -#if TARGET_OS_OSX - const CGFloat *outerTL = &_underlayCornerRadii[4]; // path top-left = visual bottom-left - const CGFloat *outerTR = &_underlayCornerRadii[6]; // path top-right = visual bottom-right - const CGFloat *outerBL = &_underlayCornerRadii[0]; // path bottom-left = visual top-left - const CGFloat *outerBR = &_underlayCornerRadii[2]; // path bottom-right = visual top-right - CGFloat borderTop = _underlayBorderInsets.bottom; - CGFloat borderBottom = _underlayBorderInsets.top; -#else const CGFloat *outerTL = &_underlayCornerRadii[0]; const CGFloat *outerTR = &_underlayCornerRadii[2]; const CGFloat *outerBL = &_underlayCornerRadii[4]; const CGFloat *outerBR = &_underlayCornerRadii[6]; CGFloat borderTop = _underlayBorderInsets.top; CGFloat borderBottom = _underlayBorderInsets.bottom; -#endif CGFloat borderLeft = _underlayBorderInsets.left; CGFloat borderRight = _underlayBorderInsets.right; From 53cf70fa39e53c30ebf03d4e36c45c09e4b98bda Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 30 Mar 2026 13:57:35 +0200 Subject: [PATCH 12/15] Add example --- .../components/button_underlay/index.tsx | 551 ++++++++++++++++++ apps/common-app/src/new_api/index.tsx | 2 + 2 files changed, 553 insertions(+) create mode 100644 apps/common-app/src/new_api/components/button_underlay/index.tsx diff --git a/apps/common-app/src/new_api/components/button_underlay/index.tsx b/apps/common-app/src/new_api/components/button_underlay/index.tsx new file mode 100644 index 0000000000..04b5573651 --- /dev/null +++ b/apps/common-app/src/new_api/components/button_underlay/index.tsx @@ -0,0 +1,551 @@ +import React from 'react'; +import { View, StyleSheet, Text, SafeAreaView } from 'react-native'; +import { + GestureHandlerRootView, + ScrollView, + RawButton, +} from 'react-native-gesture-handler'; + +const UNDERLAY_PROPS = { + underlayColor: 'red', + activeUnderlayOpacity: 0.5, + animationDuration: 200, +} as const; + +export default function UnderlayEdgeCases() { + return ( + + + + +
+ + + + + + + + + + + +
+ +
+ + + + + + + + + + + +
+ +
+ + + + + + + + + + + +
+ +
+ + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + +
+ +
+ + + + + + + + + + + +
+ +
+ + + + + + + + + + + +
+ +
+ + + + + + + + +
+ +
+ + + + + + + + +
+ +
+ + + + + + + + +
+
+
+
+
+ ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + {title} + {children} + + ); +} + +function Row({ children }: { children: React.ReactNode }) { + return {children}; +} + +function Label({ children }: { children: string }) { + return {children}; +} + +const buttonBase = { + justifyContent: 'center' as const, + alignItems: 'center' as const, +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + scrollContent: { + paddingBottom: 60, + }, + padded: { + padding: 16, + }, + section: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 14, + fontWeight: 'bold', + marginBottom: 8, + color: '#333', + }, + row: { + flexDirection: 'row', + gap: 10, + marginBottom: 10, + }, + label: { + color: '#333', + fontSize: 11, + fontWeight: '600', + }, + uniformSmall: { + flex: 1, + height: 70, + backgroundColor: '#FFD61E', + borderRadius: 8, + borderWidth: 2, + borderColor: '#000', + }, + uniformLarge: { + flex: 1, + height: 70, + backgroundColor: '#38ACDD', + borderRadius: 24, + borderWidth: 2, + borderColor: '#000', + }, + uniformPill: { + flex: 1, + height: 70, + backgroundColor: '#57B495', + borderRadius: 999, + borderWidth: 2, + borderColor: '#000', + }, + perCornerMixed: { + flex: 1, + height: 80, + backgroundColor: '#FF6259', + borderTopLeftRadius: 20, + borderTopRightRadius: 4, + borderBottomLeftRadius: 4, + borderBottomRightRadius: 20, + borderWidth: 3, + borderColor: '#000', + }, + perCornerOneZero: { + flex: 1, + height: 80, + backgroundColor: '#782AEB', + borderRadius: 16, + borderTopRightRadius: 0, + borderWidth: 3, + borderColor: '#000', + }, + perCornerDiagonal: { + flex: 1, + height: 80, + backgroundColor: '#38ACDD', + borderTopLeftRadius: 30, + borderTopRightRadius: 0, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 30, + borderWidth: 3, + borderColor: '#000', + }, + logicalStart: { + flex: 1, + height: 80, + backgroundColor: '#57B495', + borderTopStartRadius: 24, + borderBottomStartRadius: 24, + borderWidth: 3, + borderColor: '#000', + }, + logicalEnd: { + flex: 1, + height: 80, + backgroundColor: '#FFD61E', + borderTopEndRadius: 24, + borderBottomEndRadius: 24, + borderWidth: 3, + borderColor: '#000', + }, + logicalMixed: { + flex: 1, + height: 80, + backgroundColor: '#FF6259', + borderTopStartRadius: 20, + borderBottomEndRadius: 20, + borderTopEndRadius: 0, + borderBottomStartRadius: 0, + borderWidth: 3, + borderColor: '#000', + }, + oversizedUniform: { + flex: 1, + height: 80, + backgroundColor: '#782AEB', + borderRadius: 200, + borderWidth: 3, + borderColor: '#000', + }, + oversizedUneven: { + flex: 1, + height: 80, + backgroundColor: '#38ACDD', + borderTopLeftRadius: 200, + borderTopRightRadius: 10, + borderBottomLeftRadius: 10, + borderBottomRightRadius: 200, + borderWidth: 3, + borderColor: '#000', + }, + unevenBorderThickBottom: { + flex: 1, + height: 90, + backgroundColor: '#FFD61E', + borderRadius: 16, + borderWidth: 3, + borderBottomWidth: 16, + borderColor: '#000', + }, + unevenBorderThickLeft: { + flex: 1, + height: 90, + backgroundColor: '#57B495', + borderRadius: 16, + borderWidth: 3, + borderLeftWidth: 16, + borderColor: '#000', + }, + unevenBorderAllDifferent: { + flex: 1, + height: 90, + backgroundColor: '#FF6259', + borderRadius: 12, + borderTopWidth: 2, + borderRightWidth: 6, + borderBottomWidth: 14, + borderLeftWidth: 10, + borderColor: '#000', + }, + unevenBorderWithRadius: { + flex: 1, + height: 90, + backgroundColor: '#782AEB', + borderTopLeftRadius: 20, + borderTopRightRadius: 0, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 32, + borderWidth: 5, + borderBottomWidth: 16, + borderColor: '#000', + }, + unevenOversized: { + flex: 1, + height: 90, + backgroundColor: '#38ACDD', + borderRadius: 200, + borderWidth: 3, + borderBottomWidth: 20, + borderColor: '#000', + }, + unevenOversizedMixed: { + flex: 1, + height: 90, + backgroundColor: '#FFD61E', + borderRadius: 16, + borderTopRightRadius: 0, + borderBottomRightRadius: 32, + borderWidth: 5, + borderBottomWidth: 16, + borderColor: '#000', + }, + noBorderRadius: { + flex: 1, + height: 70, + backgroundColor: '#57B495', + borderWidth: 3, + borderColor: '#000', + }, + zeroBorderRadius: { + flex: 1, + height: 70, + backgroundColor: '#FF6259', + borderRadius: 0, + borderWidth: 3, + borderColor: '#000', + }, + someZeroCorners: { + flex: 1, + height: 70, + backgroundColor: '#782AEB', + borderRadius: 16, + borderTopLeftRadius: 0, + borderBottomRightRadius: 0, + borderWidth: 3, + borderColor: '#000', + }, + radiusLessThanBorder: { + flex: 1, + height: 80, + backgroundColor: '#38ACDD', + borderRadius: 5, + borderWidth: 10, + borderColor: '#000', + }, + radiusEqualsBorder: { + flex: 1, + height: 80, + backgroundColor: '#FFD61E', + borderRadius: 10, + borderWidth: 10, + borderColor: '#000', + }, + radiusSlightlyMore: { + flex: 1, + height: 80, + backgroundColor: '#57B495', + borderRadius: 12, + borderWidth: 10, + borderColor: '#000', + }, + ellipticalInner: { + flex: 1, + height: 90, + backgroundColor: '#FF6259', + borderRadius: 20, + borderWidth: 5, + borderBottomWidth: 16, + borderColor: '#000', + }, + ellipticalInnerLarge: { + flex: 1, + height: 90, + backgroundColor: '#782AEB', + borderRadius: 24, + borderWidth: 2, + borderBottomWidth: 20, + borderColor: '#000', + }, + noBorderWithRadius: { + flex: 1, + height: 70, + backgroundColor: '#38ACDD', + borderRadius: 16, + }, + noBorderPill: { + flex: 1, + height: 70, + backgroundColor: '#57B495', + borderRadius: 999, + }, + paddingSmallRadius: { + flex: 1, + height: 80, + backgroundColor: '#FFD61E', + borderRadius: 8, + borderWidth: 3, + borderColor: '#000', + padding: 16, + }, + paddingLargeRadius: { + flex: 1, + height: 80, + backgroundColor: '#FF6259', + borderRadius: 30, + borderWidth: 3, + borderBottomWidth: 12, + borderColor: '#000', + padding: 16, + }, +}); diff --git a/apps/common-app/src/new_api/index.tsx b/apps/common-app/src/new_api/index.tsx index 3c706cf91b..5a9f73e41b 100644 --- a/apps/common-app/src/new_api/index.tsx +++ b/apps/common-app/src/new_api/index.tsx @@ -27,6 +27,7 @@ import RotationExample from './simple/rotation'; import TapExample from './simple/tap'; import ButtonsExample from './components/buttons'; +import ButtonUnderlayExample from './components/button_underlay'; import ReanimatedDrawerLayout from './components/drawer'; import FlatListExample from './components/flatlist'; import ScrollViewExample from './components/scrollview'; @@ -105,6 +106,7 @@ export const NEW_EXAMPLES: ExamplesSection[] = [ { name: 'FlatList example', component: FlatListExample }, { name: 'ScrollView example', component: ScrollViewExample }, { name: 'Buttons example', component: ButtonsExample }, + { name: 'Button underlay example', component: ButtonUnderlayExample }, { name: 'Switch & TextInput', component: SwitchTextInputExample }, { name: 'Reanimated Swipeable', component: Swipeable }, { name: 'Reanimated Drawer Layout', component: ReanimatedDrawerLayout }, From 5fc983a8fabcf5ac9f1c23f30966c2c5081f90db Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 30 Mar 2026 15:11:06 +0200 Subject: [PATCH 13/15] Remove redundant clipping --- .../RNGestureHandlerButtonViewManager.kt | 155 ++---------------- 1 file changed, 18 insertions(+), 137 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index e5424bb5fa..31428bed99 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -175,88 +175,75 @@ class RNGestureHandlerButtonViewManager : view.setOverflow(overflow) } - private fun setBorderRadiusInternal( - view: ButtonViewGroup, - prop: BorderRadiusProp, - value: Float, - cornerIndex: Int = -2, - logicalCorner: Int = -1, - ) { - // NaN = unset (physical props), negative = unset (codegen default for logical props) + private fun setBorderRadiusInternal(view: ButtonViewGroup, prop: BorderRadiusProp, value: Float) { val isUnset = value.isNaN() || value < 0f val lp = if (isUnset) null else LengthPercentage(value, LengthPercentageType.POINT) BackgroundStyleApplicator.setBorderRadius(view, prop, lp) - if (cornerIndex >= -1) { - view.updateCornerRadius(cornerIndex, if (isUnset) -1f else value) - } - if (logicalCorner >= 0) { - view.updateLogicalCornerRadius(logicalCorner, if (isUnset) -1f else value) - } } @ReactProp(name = ViewProps.BORDER_RADIUS) override fun setBorderRadius(view: ButtonViewGroup, borderRadius: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_RADIUS, borderRadius, cornerIndex = -1) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_RADIUS, borderRadius) } @ReactProp(name = "borderTopLeftRadius") override fun setBorderTopLeftRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, value, cornerIndex = 0) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, value) } @ReactProp(name = "borderTopRightRadius") override fun setBorderTopRightRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_RIGHT_RADIUS, value, cornerIndex = 1) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_RIGHT_RADIUS, value) } @ReactProp(name = "borderBottomRightRadius") override fun setBorderBottomRightRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_RIGHT_RADIUS, value, cornerIndex = 2) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_RIGHT_RADIUS, value) } @ReactProp(name = "borderBottomLeftRadius") override fun setBorderBottomLeftRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_LEFT_RADIUS, value, cornerIndex = 3) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_LEFT_RADIUS, value) } @ReactProp(name = "borderTopStartRadius") override fun setBorderTopStartRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_START_RADIUS, value, logicalCorner = 0) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_START_RADIUS, value) } @ReactProp(name = "borderTopEndRadius") override fun setBorderTopEndRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_END_RADIUS, value, logicalCorner = 1) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_TOP_END_RADIUS, value) } @ReactProp(name = "borderBottomStartRadius") override fun setBorderBottomStartRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_START_RADIUS, value, logicalCorner = 3) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_START_RADIUS, value) } @ReactProp(name = "borderBottomEndRadius") override fun setBorderBottomEndRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_END_RADIUS, value, logicalCorner = 2) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_BOTTOM_END_RADIUS, value) } @ReactProp(name = "borderEndEndRadius") override fun setBorderEndEndRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_END_END_RADIUS, value, logicalCorner = 2) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_END_END_RADIUS, value) } @ReactProp(name = "borderEndStartRadius") override fun setBorderEndStartRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_END_START_RADIUS, value, logicalCorner = 3) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_END_START_RADIUS, value) } @ReactProp(name = "borderStartEndRadius") override fun setBorderStartEndRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_START_END_RADIUS, value, logicalCorner = 1) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_START_END_RADIUS, value) } @ReactProp(name = "borderStartStartRadius") override fun setBorderStartStartRadius(view: ButtonViewGroup, value: Float) { - setBorderRadiusInternal(view, BorderRadiusProp.BORDER_START_START_RADIUS, value, logicalCorner = 0) + setBorderRadiusInternal(view, BorderRadiusProp.BORDER_START_START_RADIUS, value) } @ReactProp(name = "rippleColor") @@ -376,13 +363,6 @@ class RNGestureHandlerButtonViewManager : override var pointerEvents: PointerEvents = PointerEvents.AUTO - // Border radii tracked for ripple mask and underlay clipping. - // BackgroundStyleApplicator handles the visual border rendering, - // but ripple/underlay drawables need the resolved radii separately. - private var generalBorderRadius = 0f - private var cornerBorderRadii: FloatArray? = null // [TL, TR, BR, BL] in dp - private var logicalBorderRadii: FloatArray? = null // [topStart, topEnd, bottomEnd, bottomStart] in dp - private var needBackgroundUpdate = false private var lastEventTime = -1L private var lastAction = -1 @@ -410,88 +390,6 @@ class RNGestureHandlerButtonViewManager : needBackgroundUpdate = true } - fun updateCornerRadius(corner: Int, radiusDp: Float) { - if (corner == -1) { - generalBorderRadius = radiusDp - } else { - if (cornerBorderRadii == null) { - cornerBorderRadii = FloatArray(4) - cornerBorderRadii!!.fill(-1f) - } - cornerBorderRadii!![corner] = radiusDp - } - needBackgroundUpdate = true - } - - fun updateLogicalCornerRadius(logicalCorner: Int, radiusDp: Float) { - if (logicalBorderRadii == null) { - logicalBorderRadii = FloatArray(4) - logicalBorderRadii!!.fill(-1f) - } - logicalBorderRadii!![logicalCorner] = radiusDp - needBackgroundUpdate = true - } - - private fun buildCornerRadii(): FloatArray? { - val corners = cornerBorderRadii - val logical = logicalBorderRadii - val general = generalBorderRadius - if (general == 0f && corners == null && logical == null) return null - - val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL - val density = resources.displayMetrics.density - - // Resolve logical → physical based on layout direction. - // logical: [topStart(0), topEnd(1), bottomEnd(2), bottomStart(3)] - // physical: [TL(0), TR(1), BR(2), BL(3)] - fun resolveLogical(physicalCorner: Int): Float { - if (logical == null) return -1f - return when (physicalCorner) { - 0 -> if (isRtl) logical[1] else logical[0] - 1 -> if (isRtl) logical[0] else logical[1] - 2 -> if (isRtl) logical[3] else logical[2] - 3 -> if (isRtl) logical[2] else logical[3] - else -> -1f - } - } - - // Priority: logical corner > physical corner > general - fun resolve(physicalCorner: Int): Float { - val logicalVal = resolveLogical(physicalCorner).takeIf { it != -1f } - if (logicalVal != null) return logicalVal * density - val physical = corners?.get(physicalCorner)?.takeIf { it != -1f } - if (physical != null) return physical * density - return general * density - } - - var tl = resolve(0) - var tr = resolve(1) - var br = resolve(2) - var bl = resolve(3) - - if (tl == 0f && tr == 0f && br == 0f && bl == 0f) return null - - // CSS border-radius proportional scaling: if adjacent radii on any edge - // exceed that edge's length, scale all radii down by the same factor. - val w = width.toFloat() - val h = height.toFloat() - if (w > 0f && h > 0f) { - var f = 1f - if (tl + tr > 0f) f = minOf(f, w / (tl + tr)) - if (bl + br > 0f) f = minOf(f, w / (bl + br)) - if (tl + bl > 0f) f = minOf(f, h / (tl + bl)) - if (tr + br > 0f) f = minOf(f, h / (tr + br)) - if (f < 1f) { - tl *= f - tr *= f - br *= f - bl *= f - } - } - - return floatArrayOf(tl, tl, tr, tr, br, br, bl, bl) - } - fun setOverflow(overflow: String?) { when (overflow) { "hidden" -> { @@ -625,9 +523,8 @@ class RNGestureHandlerButtonViewManager : animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity) } - private fun createUnderlayDrawable(radii: FloatArray?): PaintDrawable { + private fun createUnderlayDrawable(): PaintDrawable { val drawable = PaintDrawable(underlayColor ?: Color.BLACK) - radii?.let { drawable.setCornerRadii(it) } drawable.alpha = (defaultUnderlayOpacity * 255).toInt() return drawable } @@ -642,10 +539,8 @@ class RNGestureHandlerButtonViewManager : foreground = null } - val radii = buildCornerRadii() - - val selectable = createSelectableDrawable(radii) - val underlay = createUnderlayDrawable(radii) + val selectable = createSelectableDrawable() + val underlay = createUnderlayDrawable() underlayDrawable = underlay // Set this view as callback so ObjectAnimator alpha changes trigger redraws. underlay.callback = this @@ -700,7 +595,7 @@ class RNGestureHandlerButtonViewManager : } } - private fun createSelectableDrawable(radii: FloatArray?): Drawable? { + private fun createSelectableDrawable(): Drawable? { // don't create ripple drawable at all when it's not supposed to be visible if (rippleColor == Color.TRANSPARENT) { return null @@ -728,12 +623,6 @@ class RNGestureHandlerButtonViewManager : drawable.radius = PixelUtil.toPixelFromDIP(rippleRadius.toFloat()).toInt() } - if (radii != null && drawable is RippleDrawable && !useBorderlessDrawable) { - val mask = PaintDrawable(Color.WHITE) - mask.setCornerRadii(radii) - drawable.setDrawableByLayerId(android.R.id.mask, mask) - } - return drawable } @@ -743,14 +632,6 @@ class RNGestureHandlerButtonViewManager : updateBackground() } - override fun onRtlPropertiesChanged(layoutDirection: Int) { - super.onRtlPropertiesChanged(layoutDirection) - if (logicalBorderRadii != null) { - needBackgroundUpdate = true - updateBackground() - } - } - override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { // No-op } From 92852e39217d234a672f0995a5e423ae24b294ef Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 30 Mar 2026 16:03:39 +0200 Subject: [PATCH 14/15] Disable ripple --- apps/common-app/src/new_api/components/button_underlay/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/common-app/src/new_api/components/button_underlay/index.tsx b/apps/common-app/src/new_api/components/button_underlay/index.tsx index 04b5573651..1c4c54409c 100644 --- a/apps/common-app/src/new_api/components/button_underlay/index.tsx +++ b/apps/common-app/src/new_api/components/button_underlay/index.tsx @@ -10,6 +10,7 @@ const UNDERLAY_PROPS = { underlayColor: 'red', activeUnderlayOpacity: 0.5, animationDuration: 200, + rippleColor: 'transparent', } as const; export default function UnderlayEdgeCases() { From c197d85581e4b36c5f4366d8e313fca102d5ff10 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 30 Mar 2026 16:15:54 +0200 Subject: [PATCH 15/15] Format --- .../apple/RNGestureHandlerButton.mm | 76 +++++++++++++------ 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index dcab972ab8..7510c156d1 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -300,14 +300,22 @@ - (void)applyUnderlayCornerRadii // Snap sub-pixel radii to 0 to avoid degenerate curves that cause // anti-aliasing artifacts at what should be sharp corners. - if (topLeftHorizontal < 0.5) topLeftHorizontal = 0; - if (topLeftVertical < 0.5) topLeftVertical = 0; - if (topRightHorizontal < 0.5) topRightHorizontal = 0; - if (topRightVertical < 0.5) topRightVertical = 0; - if (bottomLeftHorizontal < 0.5) bottomLeftHorizontal = 0; - if (bottomLeftVertical < 0.5) bottomLeftVertical = 0; - if (bottomRightHorizontal < 0.5) bottomRightHorizontal = 0; - if (bottomRightVertical < 0.5) bottomRightVertical = 0; + if (topLeftHorizontal < 0.5) + topLeftHorizontal = 0; + if (topLeftVertical < 0.5) + topLeftVertical = 0; + if (topRightHorizontal < 0.5) + topRightHorizontal = 0; + if (topRightVertical < 0.5) + topRightVertical = 0; + if (bottomLeftHorizontal < 0.5) + bottomLeftHorizontal = 0; + if (bottomLeftVertical < 0.5) + bottomLeftVertical = 0; + if (bottomRightHorizontal < 0.5) + bottomRightHorizontal = 0; + if (bottomRightVertical < 0.5) + bottomRightVertical = 0; if (topLeftHorizontal == 0 && topLeftVertical == 0 && topRightHorizontal == 0 && topRightVertical == 0 && bottomLeftHorizontal == 0 && bottomLeftVertical == 0 && bottomRightHorizontal == 0 && bottomRightVertical == 0) { @@ -346,10 +354,15 @@ - (void)applyUnderlayCornerRadii // Top edge → top-right corner if (hasTR) { CGPathAddLineToPoint(path, NULL, w - topRightHorizontal, 0); - CGPathAddCurveToPoint(path, NULL, - w - topRightHorizontal + topRightHorizontal * k, 0, - w, topRightVertical - topRightVertical * k, - w, topRightVertical); + CGPathAddCurveToPoint( + path, + NULL, + w - topRightHorizontal + topRightHorizontal * k, + 0, + w, + topRightVertical - topRightVertical * k, + w, + topRightVertical); } else { CGPathAddLineToPoint(path, NULL, w, 0); } @@ -357,10 +370,15 @@ - (void)applyUnderlayCornerRadii // Right edge → bottom-right corner if (hasBR) { CGPathAddLineToPoint(path, NULL, w, h - bottomRightVertical); - CGPathAddCurveToPoint(path, NULL, - w, h - bottomRightVertical + bottomRightVertical * k, - w - bottomRightHorizontal + bottomRightHorizontal * k, h, - w - bottomRightHorizontal, h); + CGPathAddCurveToPoint( + path, + NULL, + w, + h - bottomRightVertical + bottomRightVertical * k, + w - bottomRightHorizontal + bottomRightHorizontal * k, + h, + w - bottomRightHorizontal, + h); } else { CGPathAddLineToPoint(path, NULL, w, h); } @@ -368,10 +386,15 @@ - (void)applyUnderlayCornerRadii // Bottom edge → bottom-left corner if (hasBL) { CGPathAddLineToPoint(path, NULL, bottomLeftHorizontal, h); - CGPathAddCurveToPoint(path, NULL, - bottomLeftHorizontal - bottomLeftHorizontal * k, h, - 0, h - bottomLeftVertical + bottomLeftVertical * k, - 0, h - bottomLeftVertical); + CGPathAddCurveToPoint( + path, + NULL, + bottomLeftHorizontal - bottomLeftHorizontal * k, + h, + 0, + h - bottomLeftVertical + bottomLeftVertical * k, + 0, + h - bottomLeftVertical); } else { CGPathAddLineToPoint(path, NULL, 0, h); } @@ -379,10 +402,15 @@ - (void)applyUnderlayCornerRadii // Left edge → top-left corner if (hasTL) { CGPathAddLineToPoint(path, NULL, 0, topLeftVertical); - CGPathAddCurveToPoint(path, NULL, - 0, topLeftVertical - topLeftVertical * k, - topLeftHorizontal - topLeftHorizontal * k, 0, - topLeftHorizontal, 0); + CGPathAddCurveToPoint( + path, + NULL, + 0, + topLeftVertical - topLeftVertical * k, + topLeftHorizontal - topLeftHorizontal * k, + 0, + topLeftHorizontal, + 0); } // closePath returns to the start point — (0,0) if no TL curve