react-native-smooth-clip-view 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +170 -0
- package/SmoothClipView.podspec +24 -0
- package/android/build.gradle +63 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/smoothclipview/ClipGeometryNormalizer.kt +93 -0
- package/android/src/main/java/com/smoothclipview/SmoothClipView.kt +190 -0
- package/android/src/main/java/com/smoothclipview/SmoothClipViewManager.kt +104 -0
- package/android/src/main/java/com/smoothclipview/SmoothClipViewPackage.kt +21 -0
- package/ios/SmoothClipGeometry.h +41 -0
- package/ios/SmoothClipView.h +9 -0
- package/ios/SmoothClipView.mm +224 -0
- package/lib/module/SmoothClipView.js +35 -0
- package/lib/module/SmoothClipView.js.map +1 -0
- package/lib/module/SmoothClipView.web.js +38 -0
- package/lib/module/SmoothClipView.web.js.map +1 -0
- package/lib/module/SmoothClipViewNativeComponent.ts +34 -0
- package/lib/module/geometry.js +41 -0
- package/lib/module/geometry.js.map +1 -0
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/index.web.js +5 -0
- package/lib/module/index.web.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/webClipPath.js +25 -0
- package/lib/module/webClipPath.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/SmoothClipView.d.ts +12 -0
- package/lib/typescript/src/SmoothClipView.d.ts.map +1 -0
- package/lib/typescript/src/SmoothClipView.web.d.ts +12 -0
- package/lib/typescript/src/SmoothClipView.web.d.ts.map +1 -0
- package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts +15 -0
- package/lib/typescript/src/SmoothClipViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/geometry.d.ts +19 -0
- package/lib/typescript/src/geometry.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +5 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/index.web.d.ts +5 -0
- package/lib/typescript/src/index.web.d.ts.map +1 -0
- package/lib/typescript/src/webClipPath.d.ts +7 -0
- package/lib/typescript/src/webClipPath.d.ts.map +1 -0
- package/package.json +201 -0
- package/src/SmoothClipView.tsx +72 -0
- package/src/SmoothClipView.web.tsx +62 -0
- package/src/SmoothClipViewNativeComponent.ts +34 -0
- package/src/geometry.ts +83 -0
- package/src/index.ts +4 -0
- package/src/index.web.ts +4 -0
- package/src/webClipPath.ts +30 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kbrattli
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
in the Software without restriction, including without limitation the rights
|
|
7
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# react-native-smooth-clip-view
|
|
2
|
+
|
|
3
|
+
Layout-free animated rounded clipping for React Native Fabric.
|
|
4
|
+
|
|
5
|
+
`SmoothClipView` keeps a fixed maximum Yoga footprint while Reanimated sends
|
|
6
|
+
one atomic clip geometry update to the native view on the UI runtime. It is
|
|
7
|
+
useful for expanding cards, sheets, maps, media, and other reveals where
|
|
8
|
+
animating layout width and height would reflow descendants.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- React Native 0.85 or newer with the New Architecture enabled
|
|
13
|
+
- React Native Reanimated 4.3 or newer
|
|
14
|
+
- iOS 16.4 or newer
|
|
15
|
+
- Android API 26 or newer
|
|
16
|
+
|
|
17
|
+
The package supports iOS, Android, and React Native Web. It does not include a
|
|
18
|
+
legacy Paper implementation.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install react-native-smooth-clip-view react-native-reanimated react-native-worklets
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For iOS, install pods after adding the package:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
cd ios && pod install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Follow the Reanimated installation instructions for your React Native setup.
|
|
33
|
+
Expo SDK 57 configures the required Babel plugin through `babel-preset-expo`.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
import { useState } from 'react';
|
|
39
|
+
import { Button, StyleSheet, View } from 'react-native';
|
|
40
|
+
import {
|
|
41
|
+
type ClipGeometry,
|
|
42
|
+
SmoothClipView,
|
|
43
|
+
} from 'react-native-smooth-clip-view';
|
|
44
|
+
import {
|
|
45
|
+
interpolate,
|
|
46
|
+
useDerivedValue,
|
|
47
|
+
useSharedValue,
|
|
48
|
+
withTiming,
|
|
49
|
+
} from 'react-native-reanimated';
|
|
50
|
+
|
|
51
|
+
const initialClip: ClipGeometry = {
|
|
52
|
+
x: 120,
|
|
53
|
+
y: 180,
|
|
54
|
+
width: 80,
|
|
55
|
+
height: 80,
|
|
56
|
+
radius: 40,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function ClipExample() {
|
|
60
|
+
const [expanded, setExpanded] = useState(false);
|
|
61
|
+
const progress = useSharedValue(0);
|
|
62
|
+
const animatedClip = useDerivedValue<ClipGeometry>(() => ({
|
|
63
|
+
x: interpolate(progress.value, [0, 1], [120, 0]),
|
|
64
|
+
y: interpolate(progress.value, [0, 1], [180, 0]),
|
|
65
|
+
width: interpolate(progress.value, [0, 1], [80, 320]),
|
|
66
|
+
height: interpolate(progress.value, [0, 1], [80, 480]),
|
|
67
|
+
radius: interpolate(progress.value, [0, 1], [40, 24]),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
const toggle = () => {
|
|
71
|
+
const next = !expanded;
|
|
72
|
+
setExpanded(next);
|
|
73
|
+
progress.value = withTiming(next ? 1 : 0);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<View>
|
|
78
|
+
<SmoothClipView
|
|
79
|
+
initialClip={initialClip}
|
|
80
|
+
animatedClip={animatedClip}
|
|
81
|
+
style={styles.host}
|
|
82
|
+
>
|
|
83
|
+
<View style={styles.content} />
|
|
84
|
+
</SmoothClipView>
|
|
85
|
+
<Button title="Toggle clip" onPress={toggle} />
|
|
86
|
+
</View>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const styles = StyleSheet.create({
|
|
91
|
+
host: { width: 320, height: 480 },
|
|
92
|
+
content: { width: 320, height: 480, backgroundColor: '#112743' },
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`initialClip` must equal `animatedClip.value` when the component mounts. This
|
|
97
|
+
lets native rendering start with the correct clip before the first UI-runtime
|
|
98
|
+
command is dispatched.
|
|
99
|
+
|
|
100
|
+
## API
|
|
101
|
+
|
|
102
|
+
### `SmoothClipView`
|
|
103
|
+
|
|
104
|
+
`SmoothClipViewProps` extends React Native `ViewProps` and adds:
|
|
105
|
+
|
|
106
|
+
| Prop | Type | Description |
|
|
107
|
+
| --- | --- | --- |
|
|
108
|
+
| `initialClip` | `ClipGeometry` | Clip rendered synchronously at mount. |
|
|
109
|
+
| `animatedClip` | `SharedValue<ClipGeometry>` | Geometry observed on the Reanimated UI runtime. |
|
|
110
|
+
| `children` | `ReactNode` | Content rendered inside the fixed host. |
|
|
111
|
+
|
|
112
|
+
### `ClipGeometry`
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
type ClipGeometry = Readonly<{
|
|
116
|
+
x: number;
|
|
117
|
+
y: number;
|
|
118
|
+
width: number;
|
|
119
|
+
height: number;
|
|
120
|
+
radius: number;
|
|
121
|
+
}>;
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Values use React Native points/DIPs. Native code rejects non-finite updates,
|
|
125
|
+
intersects the requested rectangle with the actual host bounds, prevents
|
|
126
|
+
negative sizes, and clamps `radius` to half of the visible shortest edge.
|
|
127
|
+
|
|
128
|
+
### `normalizeClipGeometry`
|
|
129
|
+
|
|
130
|
+
`normalizeClipGeometry(geometry, bounds)` mirrors the native normalization
|
|
131
|
+
contract for tests and non-native calculations. Native bounds remain
|
|
132
|
+
authoritative at render time.
|
|
133
|
+
|
|
134
|
+
## Layout and styling contract
|
|
135
|
+
|
|
136
|
+
- Give the host its fixed maximum `width` and `height`; clipping never changes
|
|
137
|
+
Yoga layout.
|
|
138
|
+
- Put visual backgrounds inside `SmoothClipView`.
|
|
139
|
+
- Borders, shadows, host corner styles, host `overflow`, per-corner radii, and
|
|
140
|
+
externally applied anisotropic transforms are unsupported.
|
|
141
|
+
- Version 0.1.0 supports one uniform corner radius.
|
|
142
|
+
- On web, the implementation uses CSS `clip-path` on the fixed host, so
|
|
143
|
+
descendant percentages and right/bottom anchoring still resolve against the
|
|
144
|
+
maximum footprint.
|
|
145
|
+
|
|
146
|
+
## Example and development
|
|
147
|
+
|
|
148
|
+
The [`example`](./example) workspace is an Expo SDK 57 app that exercises the
|
|
149
|
+
package through its public import on iOS, Android, and web.
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
yarn
|
|
153
|
+
yarn example ios
|
|
154
|
+
yarn example android
|
|
155
|
+
yarn example web
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Run the repository checks with:
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
yarn lint
|
|
162
|
+
yarn typecheck
|
|
163
|
+
yarn test
|
|
164
|
+
yarn prepare
|
|
165
|
+
yarn example build:web
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = "SmoothClipView"
|
|
7
|
+
s.version = package["version"]
|
|
8
|
+
s.summary = package["description"]
|
|
9
|
+
s.homepage = package["homepage"]
|
|
10
|
+
s.license = package["license"]
|
|
11
|
+
s.authors = package["author"]
|
|
12
|
+
|
|
13
|
+
s.platforms = { :ios => "16.4" }
|
|
14
|
+
s.source = { :git => "https://github.com/kbrattli/react-native-smooth-clip-view.git", :tag => "v#{s.version}" }
|
|
15
|
+
s.source_files = "ios/*.{h,m,mm,swift,cpp}"
|
|
16
|
+
s.private_header_files = "ios/*.h"
|
|
17
|
+
|
|
18
|
+
install_modules_dependencies(s)
|
|
19
|
+
|
|
20
|
+
s.test_spec "Tests" do |test_spec|
|
|
21
|
+
test_spec.framework = "XCTest"
|
|
22
|
+
test_spec.source_files = "ios/tests/**/*.{m,mm}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
ext.SmoothClipView = [
|
|
3
|
+
kotlinVersion: "2.0.21",
|
|
4
|
+
minSdkVersion: 26,
|
|
5
|
+
compileSdkVersion: 36,
|
|
6
|
+
targetSdkVersion: 36
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
ext.getExtOrDefault = { prop ->
|
|
10
|
+
if (rootProject.ext.has(prop)) {
|
|
11
|
+
return rootProject.ext.get(prop)
|
|
12
|
+
}
|
|
13
|
+
return SmoothClipView[prop]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
repositories {
|
|
17
|
+
google()
|
|
18
|
+
mavenCentral()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
dependencies {
|
|
22
|
+
classpath "com.android.tools.build:gradle:8.7.2"
|
|
23
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
apply plugin: "com.android.library"
|
|
28
|
+
apply plugin: "kotlin-android"
|
|
29
|
+
apply plugin: "com.facebook.react"
|
|
30
|
+
|
|
31
|
+
android {
|
|
32
|
+
namespace "com.smoothclipview"
|
|
33
|
+
compileSdkVersion getExtOrDefault("compileSdkVersion")
|
|
34
|
+
|
|
35
|
+
defaultConfig {
|
|
36
|
+
minSdkVersion getExtOrDefault("minSdkVersion")
|
|
37
|
+
targetSdkVersion getExtOrDefault("targetSdkVersion")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
buildFeatures {
|
|
41
|
+
buildConfig true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
buildTypes {
|
|
45
|
+
release {
|
|
46
|
+
minifyEnabled false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
lint {
|
|
51
|
+
disable "GradleCompatible"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
compileOptions {
|
|
55
|
+
sourceCompatibility JavaVersion.VERSION_1_8
|
|
56
|
+
targetCompatibility JavaVersion.VERSION_1_8
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
dependencies {
|
|
61
|
+
implementation "com.facebook.react:react-android"
|
|
62
|
+
testImplementation "junit:junit:4.13.2"
|
|
63
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
package com.smoothclipview
|
|
2
|
+
|
|
3
|
+
import android.view.View
|
|
4
|
+
import kotlin.math.max
|
|
5
|
+
import kotlin.math.min
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalizes clip geometry without allocating a result object. The callback is
|
|
9
|
+
* inlined into the view's command path so per-frame updates stay allocation-free.
|
|
10
|
+
*/
|
|
11
|
+
internal inline fun normalizeClipGeometryPx(
|
|
12
|
+
x: Float,
|
|
13
|
+
y: Float,
|
|
14
|
+
width: Float,
|
|
15
|
+
height: Float,
|
|
16
|
+
radius: Float,
|
|
17
|
+
hostWidth: Float,
|
|
18
|
+
hostHeight: Float,
|
|
19
|
+
apply: (
|
|
20
|
+
left: Float,
|
|
21
|
+
top: Float,
|
|
22
|
+
right: Float,
|
|
23
|
+
bottom: Float,
|
|
24
|
+
radius: Float,
|
|
25
|
+
) -> Unit,
|
|
26
|
+
): Boolean {
|
|
27
|
+
if (!x.isFinite() || !y.isFinite() || !width.isFinite() ||
|
|
28
|
+
!height.isFinite() || !radius.isFinite() ||
|
|
29
|
+
!hostWidth.isFinite() || !hostHeight.isFinite()
|
|
30
|
+
) {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
val boundedHostWidth = hostWidth.coerceAtLeast(0f)
|
|
35
|
+
val boundedHostHeight = hostHeight.coerceAtLeast(0f)
|
|
36
|
+
val requestedRight = x + width.coerceAtLeast(0f)
|
|
37
|
+
val requestedBottom = y + height.coerceAtLeast(0f)
|
|
38
|
+
val left = x.coerceIn(0f, boundedHostWidth)
|
|
39
|
+
val top = y.coerceIn(0f, boundedHostHeight)
|
|
40
|
+
val right = requestedRight.coerceIn(0f, boundedHostWidth)
|
|
41
|
+
val bottom = requestedBottom.coerceIn(0f, boundedHostHeight)
|
|
42
|
+
val visibleWidth = max(0f, right - left)
|
|
43
|
+
val visibleHeight = max(0f, bottom - top)
|
|
44
|
+
val normalizedRadius = min(
|
|
45
|
+
radius.coerceAtLeast(0f),
|
|
46
|
+
min(visibleWidth, visibleHeight) / 2f,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
apply(left, top, right, bottom, normalizedRadius)
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
internal fun clipVisibility(isEmpty: Boolean): Int =
|
|
54
|
+
if (isEmpty) View.INVISIBLE else View.VISIBLE
|
|
55
|
+
|
|
56
|
+
internal fun clipAccessibility(
|
|
57
|
+
isEmpty: Boolean,
|
|
58
|
+
requestedAccessibility: Int,
|
|
59
|
+
): Int =
|
|
60
|
+
if (isEmpty) {
|
|
61
|
+
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|
|
62
|
+
} else {
|
|
63
|
+
requestedAccessibility
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
internal fun containsRoundedPointPx(
|
|
67
|
+
x: Float,
|
|
68
|
+
y: Float,
|
|
69
|
+
left: Float,
|
|
70
|
+
top: Float,
|
|
71
|
+
right: Float,
|
|
72
|
+
bottom: Float,
|
|
73
|
+
radius: Float,
|
|
74
|
+
isEmpty: Boolean,
|
|
75
|
+
): Boolean {
|
|
76
|
+
if (isEmpty || x < left || x > right || y < top || y > bottom) {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (radius <= 0f) return true
|
|
81
|
+
|
|
82
|
+
val innerLeft = left + radius
|
|
83
|
+
val innerRight = right - radius
|
|
84
|
+
val innerTop = top + radius
|
|
85
|
+
val innerBottom = bottom - radius
|
|
86
|
+
if (x in innerLeft..innerRight || y in innerTop..innerBottom) return true
|
|
87
|
+
|
|
88
|
+
val centerX = if (x < innerLeft) innerLeft else innerRight
|
|
89
|
+
val centerY = if (y < innerTop) innerTop else innerBottom
|
|
90
|
+
val dx = x - centerX
|
|
91
|
+
val dy = y - centerY
|
|
92
|
+
return dx * dx + dy * dy <= radius * radius
|
|
93
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
package com.smoothclipview
|
|
2
|
+
|
|
3
|
+
import android.graphics.Outline
|
|
4
|
+
import android.view.MotionEvent
|
|
5
|
+
import android.view.View
|
|
6
|
+
import android.view.ViewOutlineProvider
|
|
7
|
+
import com.facebook.react.uimanager.PixelUtil
|
|
8
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
9
|
+
import com.facebook.react.views.view.ReactViewGroup
|
|
10
|
+
import kotlin.math.roundToInt
|
|
11
|
+
|
|
12
|
+
class SmoothClipView(context: ThemedReactContext) : ReactViewGroup(context) {
|
|
13
|
+
private var requestedX = 0f
|
|
14
|
+
private var requestedY = 0f
|
|
15
|
+
private var requestedWidth = 0f
|
|
16
|
+
private var requestedHeight = 0f
|
|
17
|
+
private var requestedRadius = 0f
|
|
18
|
+
private var clipLeft = 0f
|
|
19
|
+
private var clipTop = 0f
|
|
20
|
+
private var clipRight = 0f
|
|
21
|
+
private var clipBottom = 0f
|
|
22
|
+
private var clipRadius = 0f
|
|
23
|
+
private var clipIsEmpty = true
|
|
24
|
+
private var requestedImportantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_AUTO
|
|
25
|
+
private var acceptingTouchStream = false
|
|
26
|
+
|
|
27
|
+
private val clipOutlineProvider = object : ViewOutlineProvider() {
|
|
28
|
+
override fun getOutline(view: View, outline: Outline) {
|
|
29
|
+
if (clipIsEmpty) {
|
|
30
|
+
outline.setEmpty()
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
outline.setRoundRect(
|
|
35
|
+
clipLeft.roundToInt(),
|
|
36
|
+
clipTop.roundToInt(),
|
|
37
|
+
clipRight.roundToInt(),
|
|
38
|
+
clipBottom.roundToInt(),
|
|
39
|
+
clipRadius,
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
init {
|
|
45
|
+
outlineProvider = clipOutlineProvider
|
|
46
|
+
clipToOutline = true
|
|
47
|
+
visibility = INVISIBLE
|
|
48
|
+
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fun setClipGeometryDip(
|
|
52
|
+
x: Double,
|
|
53
|
+
y: Double,
|
|
54
|
+
width: Double,
|
|
55
|
+
height: Double,
|
|
56
|
+
radius: Double,
|
|
57
|
+
) {
|
|
58
|
+
if (!x.isFinite() || !y.isFinite() || !width.isFinite() ||
|
|
59
|
+
!height.isFinite() || !radius.isFinite()
|
|
60
|
+
) {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
val nextX = PixelUtil.toPixelFromDIP(x).toFloat()
|
|
65
|
+
val nextY = PixelUtil.toPixelFromDIP(y).toFloat()
|
|
66
|
+
val nextWidth = PixelUtil.toPixelFromDIP(width).toFloat()
|
|
67
|
+
val nextHeight = PixelUtil.toPixelFromDIP(height).toFloat()
|
|
68
|
+
val nextRadius = PixelUtil.toPixelFromDIP(radius).toFloat()
|
|
69
|
+
if (!nextX.isFinite() || !nextY.isFinite() || !nextWidth.isFinite() ||
|
|
70
|
+
!nextHeight.isFinite() || !nextRadius.isFinite()
|
|
71
|
+
) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
requestedX = nextX
|
|
76
|
+
requestedY = nextY
|
|
77
|
+
requestedWidth = nextWidth
|
|
78
|
+
requestedHeight = nextHeight
|
|
79
|
+
requestedRadius = nextRadius
|
|
80
|
+
applyRequestedGeometry()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private fun applyRequestedGeometry() {
|
|
84
|
+
normalizeClipGeometryPx(
|
|
85
|
+
requestedX,
|
|
86
|
+
requestedY,
|
|
87
|
+
requestedWidth,
|
|
88
|
+
requestedHeight,
|
|
89
|
+
requestedRadius,
|
|
90
|
+
width.toFloat(),
|
|
91
|
+
height.toFloat(),
|
|
92
|
+
) { left, top, right, bottom, radius ->
|
|
93
|
+
val isEmpty = right <= left || bottom <= top
|
|
94
|
+
val geometryChanged =
|
|
95
|
+
left != clipLeft || top != clipTop || right != clipRight ||
|
|
96
|
+
bottom != clipBottom || radius != clipRadius
|
|
97
|
+
|
|
98
|
+
if (!geometryChanged && isEmpty == clipIsEmpty) {
|
|
99
|
+
return@normalizeClipGeometryPx
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
clipLeft = left
|
|
103
|
+
clipTop = top
|
|
104
|
+
clipRight = right
|
|
105
|
+
clipBottom = bottom
|
|
106
|
+
clipRadius = radius
|
|
107
|
+
|
|
108
|
+
if (isEmpty != clipIsEmpty) {
|
|
109
|
+
clipIsEmpty = isEmpty
|
|
110
|
+
visibility = clipVisibility(isEmpty)
|
|
111
|
+
importantForAccessibility = clipAccessibility(
|
|
112
|
+
isEmpty,
|
|
113
|
+
requestedImportantForAccessibility,
|
|
114
|
+
)
|
|
115
|
+
if (isEmpty) acceptingTouchStream = false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
invalidateOutline()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
123
|
+
super.onSizeChanged(w, h, oldw, oldh)
|
|
124
|
+
applyRequestedGeometry()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private fun containsRoundedPoint(x: Float, y: Float): Boolean {
|
|
128
|
+
return containsRoundedPointPx(
|
|
129
|
+
x,
|
|
130
|
+
y,
|
|
131
|
+
clipLeft,
|
|
132
|
+
clipTop,
|
|
133
|
+
clipRight,
|
|
134
|
+
clipBottom,
|
|
135
|
+
clipRadius,
|
|
136
|
+
clipIsEmpty,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
|
141
|
+
when (event.actionMasked) {
|
|
142
|
+
MotionEvent.ACTION_DOWN -> {
|
|
143
|
+
acceptingTouchStream = containsRoundedPoint(event.x, event.y)
|
|
144
|
+
if (!acceptingTouchStream) return false
|
|
145
|
+
}
|
|
146
|
+
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
|
|
147
|
+
val result = if (acceptingTouchStream) {
|
|
148
|
+
super.dispatchTouchEvent(event)
|
|
149
|
+
} else {
|
|
150
|
+
false
|
|
151
|
+
}
|
|
152
|
+
acceptingTouchStream = false
|
|
153
|
+
return result
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return acceptingTouchStream && super.dispatchTouchEvent(event)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fun setRequestedImportantForAccessibility(value: Int) {
|
|
161
|
+
requestedImportantForAccessibility = value
|
|
162
|
+
reapplyClipPresentation()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
fun reapplyClipPresentation() {
|
|
166
|
+
val expectedVisibility = clipVisibility(clipIsEmpty)
|
|
167
|
+
if (visibility != expectedVisibility) {
|
|
168
|
+
visibility = expectedVisibility
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
val expectedAccessibility = clipAccessibility(
|
|
172
|
+
clipIsEmpty,
|
|
173
|
+
requestedImportantForAccessibility,
|
|
174
|
+
)
|
|
175
|
+
if (importantForAccessibility != expectedAccessibility) {
|
|
176
|
+
importantForAccessibility = expectedAccessibility
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fun resetClipState() {
|
|
181
|
+
acceptingTouchStream = false
|
|
182
|
+
requestedX = 0f
|
|
183
|
+
requestedY = 0f
|
|
184
|
+
requestedWidth = 0f
|
|
185
|
+
requestedHeight = 0f
|
|
186
|
+
requestedRadius = 0f
|
|
187
|
+
requestedImportantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_AUTO
|
|
188
|
+
applyRequestedGeometry()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
package com.smoothclipview
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
4
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
5
|
+
import com.facebook.react.uimanager.ViewGroupManager
|
|
6
|
+
import com.facebook.react.uimanager.ViewManagerDelegate
|
|
7
|
+
import com.facebook.react.uimanager.annotations.ReactProp
|
|
8
|
+
import com.facebook.react.viewmanagers.SmoothClipViewManagerDelegate
|
|
9
|
+
import com.facebook.react.viewmanagers.SmoothClipViewManagerInterface
|
|
10
|
+
|
|
11
|
+
@ReactModule(name = SmoothClipViewManager.NAME)
|
|
12
|
+
class SmoothClipViewManager : ViewGroupManager<SmoothClipView>(),
|
|
13
|
+
SmoothClipViewManagerInterface<SmoothClipView> {
|
|
14
|
+
private val delegate =
|
|
15
|
+
SmoothClipViewManagerDelegate<SmoothClipView, SmoothClipViewManager>(this)
|
|
16
|
+
|
|
17
|
+
private data class PendingInitialClip(
|
|
18
|
+
var x: Double = 0.0,
|
|
19
|
+
var y: Double = 0.0,
|
|
20
|
+
var width: Double = 0.0,
|
|
21
|
+
var height: Double = 0.0,
|
|
22
|
+
var radius: Double = 0.0,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
private val pendingInitialClips = java.util.WeakHashMap<SmoothClipView, PendingInitialClip>()
|
|
26
|
+
|
|
27
|
+
override fun getName(): String = NAME
|
|
28
|
+
|
|
29
|
+
override fun getDelegate(): ViewManagerDelegate<SmoothClipView> = delegate
|
|
30
|
+
|
|
31
|
+
override fun createViewInstance(context: ThemedReactContext): SmoothClipView =
|
|
32
|
+
SmoothClipView(context).also { pendingInitialClips[it] = PendingInitialClip() }
|
|
33
|
+
|
|
34
|
+
override fun setImportantForAccessibility(
|
|
35
|
+
view: SmoothClipView,
|
|
36
|
+
importantForAccessibility: String?,
|
|
37
|
+
) {
|
|
38
|
+
super.setImportantForAccessibility(view, importantForAccessibility)
|
|
39
|
+
view.setRequestedImportantForAccessibility(view.importantForAccessibility)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@ReactProp(name = "initialClipX")
|
|
43
|
+
override fun setInitialClipX(view: SmoothClipView, value: Double) {
|
|
44
|
+
pendingInitialClips.getOrPut(view) { PendingInitialClip() }.x = value
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@ReactProp(name = "initialClipY")
|
|
48
|
+
override fun setInitialClipY(view: SmoothClipView, value: Double) {
|
|
49
|
+
pendingInitialClips.getOrPut(view) { PendingInitialClip() }.y = value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@ReactProp(name = "initialClipWidth")
|
|
53
|
+
override fun setInitialClipWidth(view: SmoothClipView, value: Double) {
|
|
54
|
+
pendingInitialClips.getOrPut(view) { PendingInitialClip() }.width = value
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@ReactProp(name = "initialClipHeight")
|
|
58
|
+
override fun setInitialClipHeight(view: SmoothClipView, value: Double) {
|
|
59
|
+
pendingInitialClips.getOrPut(view) { PendingInitialClip() }.height = value
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@ReactProp(name = "initialClipRadius")
|
|
63
|
+
override fun setInitialClipRadius(view: SmoothClipView, value: Double) {
|
|
64
|
+
pendingInitialClips.getOrPut(view) { PendingInitialClip() }.radius = value
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
override fun setClipGeometry(
|
|
68
|
+
view: SmoothClipView,
|
|
69
|
+
x: Double,
|
|
70
|
+
y: Double,
|
|
71
|
+
width: Double,
|
|
72
|
+
height: Double,
|
|
73
|
+
radius: Double,
|
|
74
|
+
) {
|
|
75
|
+
pendingInitialClips.remove(view)
|
|
76
|
+
view.setClipGeometryDip(x, y, width, height, radius)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
override fun onAfterUpdateTransaction(view: SmoothClipView) {
|
|
80
|
+
super.onAfterUpdateTransaction(view)
|
|
81
|
+
val initial = pendingInitialClips.remove(view)
|
|
82
|
+
if (initial == null) {
|
|
83
|
+
view.reapplyClipPresentation()
|
|
84
|
+
} else {
|
|
85
|
+
view.setClipGeometryDip(
|
|
86
|
+
initial.x,
|
|
87
|
+
initial.y,
|
|
88
|
+
initial.width,
|
|
89
|
+
initial.height,
|
|
90
|
+
initial.radius,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
override fun onDropViewInstance(view: SmoothClipView) {
|
|
96
|
+
pendingInitialClips.remove(view)
|
|
97
|
+
view.resetClipState()
|
|
98
|
+
super.onDropViewInstance(view)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
companion object {
|
|
102
|
+
const val NAME = "SmoothClipView"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
package com.smoothclipview
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
7
|
+
import com.facebook.react.uimanager.ViewManager
|
|
8
|
+
|
|
9
|
+
class SmoothClipViewPackage : BaseReactPackage() {
|
|
10
|
+
override fun createViewManagers(
|
|
11
|
+
reactContext: ReactApplicationContext,
|
|
12
|
+
): List<ViewManager<*, *>> = listOf(SmoothClipViewManager())
|
|
13
|
+
|
|
14
|
+
override fun getModule(
|
|
15
|
+
name: String,
|
|
16
|
+
reactContext: ReactApplicationContext,
|
|
17
|
+
): NativeModule? = null
|
|
18
|
+
|
|
19
|
+
override fun getReactModuleInfoProvider() =
|
|
20
|
+
ReactModuleInfoProvider { emptyMap() }
|
|
21
|
+
}
|