Skip to content

Commit 1eed917

Browse files
committed
Share gradient stop calculations with Kotlin Multiplatform
1 parent f109c0a commit 1eed917

31 files changed

Lines changed: 1788 additions & 167 deletions

File tree

.github/workflows/test-all.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ jobs:
4141
if: github.repository == 'react/react-native'
4242
outputs:
4343
any_code_change: ${{ steps.filter_exclusions.outputs.any_code_change == 'true' || github.event_name != 'pull_request' }}
44-
should_test_android: ${{ steps.filter_exclusions.outputs.should_test_android == 'true' || github.event_name != 'pull_request' }}
45-
should_test_ios: ${{ steps.filter_exclusions.outputs.should_test_ios == 'true' || github.event_name != 'pull_request' }}
44+
should_test_android: ${{ steps.filter_exclusions.outputs.should_test_android == 'true' || steps.filter_inclusions.outputs.react_shared == 'true' || github.event_name != 'pull_request' }}
45+
should_test_ios: ${{ steps.filter_exclusions.outputs.should_test_ios == 'true' || steps.filter_inclusions.outputs.react_shared == 'true' || github.event_name != 'pull_request' }}
4646
debugger_shell: ${{ steps.filter_inclusions.outputs.debugger_shell }}
4747
steps:
4848
- name: Checkout
@@ -85,6 +85,9 @@ jobs:
8585
id: filter_inclusions
8686
with:
8787
filters: |
88+
# Shared Kotlin and its build configuration affect both platforms.
89+
react_shared:
90+
- 'packages/react-native/ReactShared/**'
8891
debugger_shell:
8992
- 'packages/debugger-shell/**'
9093
- 'scripts/debugger-shell/**'

.github/workflows/test-kmp.yml

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
name: Test Kotlin Multiplatform pilot
2+
3+
on:
4+
workflow_dispatch:
5+
pull_request:
6+
paths:
7+
- '.github/workflows/test-kmp.yml'
8+
- '.github/actions/setup-gradle/**'
9+
- '.github/actions/setup-xcode/**'
10+
- 'Gemfile'
11+
- 'Gemfile.lock'
12+
- 'settings.gradle.kts'
13+
- 'packages/react-native/ReactShared/**'
14+
- 'packages/react-native/ReactAndroid/build.gradle.kts'
15+
- 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt'
16+
- 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt'
17+
- 'packages/react-native/React/Fabric/Utils/RCTGradientUtils.*'
18+
- 'packages/react-native/React/React-RCTFabric.podspec'
19+
- 'packages/react-native/scripts/react_native_pods.rb'
20+
- 'packages/react-native/scripts/cocoapods/kmp.rb'
21+
- 'packages/react-native/Package.swift'
22+
- 'packages/react-native/package.json'
23+
- 'packages/react-native/settings.gradle.kts'
24+
push:
25+
branches:
26+
- main
27+
- '*-stable'
28+
paths:
29+
- '.github/workflows/test-kmp.yml'
30+
- '.github/actions/setup-gradle/**'
31+
- '.github/actions/setup-xcode/**'
32+
- 'Gemfile'
33+
- 'Gemfile.lock'
34+
- 'settings.gradle.kts'
35+
- 'packages/react-native/ReactShared/**'
36+
- 'packages/react-native/ReactAndroid/build.gradle.kts'
37+
- 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt'
38+
- 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt'
39+
- 'packages/react-native/React/Fabric/Utils/RCTGradientUtils.*'
40+
- 'packages/react-native/React/React-RCTFabric.podspec'
41+
- 'packages/react-native/scripts/react_native_pods.rb'
42+
- 'packages/react-native/scripts/cocoapods/kmp.rb'
43+
- 'packages/react-native/Package.swift'
44+
- 'packages/react-native/package.json'
45+
- 'packages/react-native/settings.gradle.kts'
46+
47+
permissions:
48+
contents: read
49+
50+
concurrency:
51+
group: kmp-${{ github.workflow }}-${{ github.ref }}
52+
cancel-in-progress: true
53+
54+
jobs:
55+
test-shared:
56+
runs-on: macos-26
57+
timeout-minutes: 45
58+
steps:
59+
- name: Checkout
60+
uses: actions/checkout@v6
61+
- name: Set up JDK 17
62+
uses: actions/setup-java@v5
63+
with:
64+
java-version: '17'
65+
distribution: 'zulu'
66+
- name: Set up Xcode for Kotlin Native
67+
uses: ./.github/actions/setup-xcode
68+
with:
69+
xcode-version: '26.4.1'
70+
- name: Set up Gradle
71+
uses: ./.github/actions/setup-gradle
72+
- name: Set up Ruby and CocoaPods
73+
uses: ruby/setup-ruby@v1
74+
with:
75+
ruby-version: '3.2.0'
76+
bundler-cache: true
77+
- name: Check CocoaPods linking configurations
78+
run: bundle exec ruby packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb
79+
- name: Cache Kotlin Native toolchain
80+
uses: actions/cache@v5
81+
with:
82+
path: ~/.konan
83+
key: kmp-native-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/react-native/ReactShared/*.gradle.kts', 'packages/react-native/ReactShared/gradle.properties', 'packages/react-native/ReactShared/gradle/wrapper/gradle-wrapper.properties') }}
84+
- name: Test shared code on JVM and iOS
85+
working-directory: packages/react-native/ReactShared
86+
run: >-
87+
./gradlew
88+
jvmTest iosSimulatorArm64Test
89+
linkDebugFrameworkIosArm64 linkReleaseFrameworkIosArm64
90+
linkDebugFrameworkIosSimulatorArm64 linkReleaseFrameworkIosSimulatorArm64
91+
linkDebugFrameworkIosX64 linkReleaseFrameworkIosX64
92+
--stacktrace --console=plain
93+
- name: Compare the Apple adapter with native gradients
94+
working-directory: packages/react-native/ReactShared
95+
run: ./scripts/test-apple-gradient.sh
96+
- name: Compare optimized Apple gradients
97+
working-directory: packages/react-native/ReactShared
98+
env:
99+
RCT_KMP_BUILD_TYPE: Release
100+
run: ./scripts/test-apple-gradient.sh
101+
- name: Upload test reports
102+
if: always()
103+
uses: actions/upload-artifact@v6
104+
with:
105+
name: kotlin-multiplatform-test-results
106+
path: |
107+
packages/react-native/ReactShared/build/reports/tests
108+
packages/react-native/ReactShared/build/test-results
109+
if-no-files-found: warn

build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ nexusPublishing {
7272
tasks.register("clean", Delete::class.java) {
7373
description = "Remove all the build files and intermediate build outputs"
7474
dependsOn(gradle.includedBuild("gradle-plugin").task(":clean"))
75+
dependsOn(gradle.includedBuild("react-native-shared").task(":clean"))
7576
subprojects.forEach {
7677
if (
7778
it.project.plugins.hasPlugin("com.android.library") ||
@@ -103,6 +104,7 @@ tasks.register("clean", Delete::class.java) {
103104
tasks.register("build") {
104105
description = "Build and test all the React Native relevant projects."
105106
dependsOn(gradle.includedBuild("gradle-plugin").task(":build"))
107+
dependsOn(gradle.includedBuild("react-native-shared").task(":jvmTest"))
106108
}
107109

108110
tasks.register("publishAllToMavenTempLocal") {

packages/react-native/Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
import Foundation
1010
import PackageDescription
1111

12+
if ProcessInfo.processInfo.environment["RCT_USE_KMP"] == "1" {
13+
fatalError("RCT_USE_KMP=1 currently supports CocoaPods source builds only. Run RCT_USE_KMP=1 pod install in the iOS app; the SwiftPM prebuild does not include ReactNativeShared.")
14+
}
15+
1216
let BUILD_FROM_SOURCE = false
1317

1418
// Removing the legacy TurboModule and component interop layers is opt-in while those

packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,18 @@
88
#import "RCTGradientUtils.h"
99
#import <React/RCTAnimationUtils.h>
1010
#import <React/RCTConversions.h>
11+
#import <TargetConditionals.h>
1112
#import <react/utils/FloatComparison.h>
1213
#include <optional>
1314
#import <vector>
1415

16+
#if RCT_USE_KMP && TARGET_OS_IOS && !TARGET_OS_MACCATALYST
17+
#define RCT_GRADIENT_USE_KMP 1
18+
#import <ReactNativeShared/ReactNativeShared.h>
19+
#else
20+
#define RCT_GRADIENT_USE_KMP 0
21+
#endif
22+
1523
using namespace facebook::react;
1624

1725
namespace {
@@ -156,6 +164,45 @@ CGSize calculateMultipliers(CGSize bounds)
156164
return std::nullopt;
157165
}
158166

167+
#if RCT_GRADIENT_USE_KMP
168+
static std::vector<ProcessedColorStop> resolveSharedColorStops(
169+
const std::vector<ColorStop> &colorStops,
170+
CGFloat gradientLineLength)
171+
{
172+
NSMutableArray<RNSGradientStopInput *> *inputs = [NSMutableArray arrayWithCapacity:colorStops.size()];
173+
for (const auto &stop : colorStops) {
174+
auto position = resolveColorStopPosition(stop.position, gradientLineLength);
175+
RNSDouble *boxedPosition = position.has_value() ? [RNSDouble numberWithDouble:position.value()] : nil;
176+
auto input = [[RNSGradientStopInput alloc] initWithPosition:boxedPosition hasColor:static_cast<bool>(stop.color)];
177+
[inputs addObject:input];
178+
}
179+
180+
auto resolved = [RNSGradientStops.shared resolveStops:inputs epsilon:kDefaultEpsilon useDoublePrecision:YES];
181+
std::vector<ProcessedColorStop> result;
182+
result.reserve(resolved.count);
183+
NSArray<NSNumber *> *inputRange = @[ @0.0, @1.0 ];
184+
for (RNSResolvedGradientStop *stop in resolved) {
185+
const auto &leftColor = colorStops[stop.leftColorIndex].color;
186+
SharedColor color;
187+
if (stop.leftColorIndex == stop.rightColorIndex) {
188+
// Preserve the original native color, including dynamic UIColor behavior.
189+
color = leftColor;
190+
} else if (std::isfinite(stop.weight)) {
191+
const auto &rightColor = colorStops[stop.rightColorIndex].color;
192+
NSArray<UIColor *> *outputRange =
193+
@[ RCTUIColorFromSharedColor(leftColor), RCTUIColorFromSharedColor(rightColor) ];
194+
auto interpolatedColor = RCTInterpolateColorInRange(stop.weight, inputRange, outputRange);
195+
auto alpha = (interpolatedColor >> 24) & 0xFF;
196+
auto red = (interpolatedColor >> 16) & 0xFF;
197+
auto green = (interpolatedColor >> 8) & 0xFF;
198+
auto blue = interpolatedColor & 0xFF;
199+
color = colorFromRGBA(red, green, blue, alpha);
200+
}
201+
result.push_back({.color = color, .position = stop.position});
202+
}
203+
return result;
204+
}
205+
#else
159206
// Spec: https://drafts.csswg.org/css-images-4/#coloring-gradient-line (Refer transition hint section)
160207
// Browsers add 9 intermediate color stops when a transition hint is present
161208
// Algorithm is referred from Blink engine
@@ -261,12 +308,16 @@ CGSize calculateMultipliers(CGSize bounds)
261308

262309
return colorStops;
263310
}
311+
#endif
264312

265313
@implementation RCTGradientUtils
266314
// https://drafts.csswg.org/css-images-4/#color-stop-fixup
267315
+ (std::vector<ProcessedColorStop>)getFixedColorStops:(const std::vector<ColorStop> &)colorStops
268316
gradientLineLength:(CGFloat)gradientLineLength
269317
{
318+
#if RCT_GRADIENT_USE_KMP
319+
return resolveSharedColorStops(colorStops, gradientLineLength);
320+
#else
270321
if (colorStops.empty()) {
271322
return {};
272323
}
@@ -334,6 +385,7 @@ @implementation RCTGradientUtils
334385
}
335386
}
336387
return processColorTransitionHints(fixedColorStops);
388+
#endif
337389
}
338390

339391
// CAGradientLayer linear gradient squishes the non-square gradient to square gradient.

packages/react-native/React/React-RCTFabric.podspec

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ else
1717
end
1818

1919
new_arch_flags = ENV['RCT_NEW_ARCH_ENABLED'] == '1' ? ' -DRCT_NEW_ARCH_ENABLED=1' : ''
20+
kmp_enabled = ENV['RCT_USE_KMP'] == '1'
21+
if kmp_enabled && ENV['RCT_USE_PREBUILT_RNCORE'] != '0'
22+
raise 'RCT_USE_KMP=1 requires React Native core source builds. Use use_react_native! or set RCT_USE_PREBUILT_RNCORE=0.'
23+
end
2024

2125
header_search_paths = [
2226
"\"$(PODS_TARGET_SRCROOT)/ReactCommon\"",
@@ -50,13 +54,26 @@ Pod::Spec.new do |s|
5054
s.module_name = module_name
5155
s.weak_framework = "JavaScriptCore"
5256
s.framework = "MobileCoreServices"
53-
s.pod_target_xcconfig = {
57+
pod_target_xcconfig = {
5458
"HEADER_SEARCH_PATHS" => header_search_paths,
5559
"OTHER_CFLAGS" => "$(inherited) " + new_arch_flags,
5660
"CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard()
5761
}.merge!(ENV['USE_FRAMEWORKS'] != nil ? {
5862
"PUBLIC_HEADERS_FOLDER_PATH" => "#{module_name}.framework/Headers/#{header_dir}"
5963
}: {})
64+
if kmp_enabled
65+
# Catalyst has no Kotlin/Native target. Its build keeps the existing implementation.
66+
s.dependency 'React-KMP'
67+
pod_target_xcconfig.merge!({
68+
'GCC_PREPROCESSOR_DEFINITIONS[sdk=iphoneos*]' => '$(inherited) RCT_USE_KMP=1',
69+
'GCC_PREPROCESSOR_DEFINITIONS[sdk=iphonesimulator*]' => '$(inherited) RCT_USE_KMP=1',
70+
'FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]' => '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"',
71+
'FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]' => '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"',
72+
'OTHER_LDFLAGS[sdk=iphoneos*]' => '$(inherited) -framework ReactNativeShared',
73+
'OTHER_LDFLAGS[sdk=iphonesimulator*]' => '$(inherited) -framework ReactNativeShared',
74+
})
75+
end
76+
s.pod_target_xcconfig = pod_target_xcconfig
6077

6178
s.dependency "React-Core"
6279
s.dependency "React-RCTImage"

packages/react-native/ReactAndroid/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,12 @@ tasks.withType<KotlinCompile>().configureEach {
717717
}
718718

719719
dependencies {
720+
// Embed the common Kotlin implementation in react-android's AAR. This keeps published
721+
// consumers on the existing artifact instead of requiring a separate KMP publication.
722+
implementation(
723+
files("$reactNativeRootDir/ReactShared/build/android/react-native-shared.jar")
724+
.builtBy(gradle.includedBuild("react-native-shared").task(":exportAndroidJar"))
725+
)
720726
api(libs.androidx.appcompat)
721727
api(libs.androidx.appcompat.resources)
722728
api(libs.androidx.autofill)

0 commit comments

Comments
 (0)