Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/test-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ jobs:
if: github.repository == 'react/react-native'
outputs:
any_code_change: ${{ steps.filter_exclusions.outputs.any_code_change == 'true' || github.event_name != 'pull_request' }}
should_test_android: ${{ steps.filter_exclusions.outputs.should_test_android == 'true' || github.event_name != 'pull_request' }}
should_test_ios: ${{ steps.filter_exclusions.outputs.should_test_ios == 'true' || github.event_name != 'pull_request' }}
should_test_android: ${{ steps.filter_exclusions.outputs.should_test_android == 'true' || steps.filter_inclusions.outputs.react_shared == 'true' || github.event_name != 'pull_request' }}
should_test_ios: ${{ steps.filter_exclusions.outputs.should_test_ios == 'true' || steps.filter_inclusions.outputs.react_shared == 'true' || github.event_name != 'pull_request' }}
debugger_shell: ${{ steps.filter_inclusions.outputs.debugger_shell }}
steps:
- name: Checkout
Expand Down Expand Up @@ -85,6 +85,9 @@ jobs:
id: filter_inclusions
with:
filters: |
# Shared Kotlin and its build configuration affect both platforms.
react_shared:
- 'packages/react-native/ReactShared/**'
debugger_shell:
- 'packages/debugger-shell/**'
- 'scripts/debugger-shell/**'
Expand Down
370 changes: 370 additions & 0 deletions .github/workflows/test-kmp.yml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ nexusPublishing {
tasks.register("clean", Delete::class.java) {
description = "Remove all the build files and intermediate build outputs"
dependsOn(gradle.includedBuild("gradle-plugin").task(":clean"))
dependsOn(gradle.includedBuild("react-native-shared").task(":clean"))
subprojects.forEach {
if (
it.project.plugins.hasPlugin("com.android.library") ||
Expand Down Expand Up @@ -103,6 +104,7 @@ tasks.register("clean", Delete::class.java) {
tasks.register("build") {
description = "Build and test all the React Native relevant projects."
dependsOn(gradle.includedBuild("gradle-plugin").task(":build"))
dependsOn(gradle.includedBuild("react-native-shared").task(":jvmTest"))
}

tasks.register("publishAllToMavenTempLocal") {
Expand Down
4 changes: 4 additions & 0 deletions packages/react-native/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
import Foundation
import PackageDescription

if ProcessInfo.processInfo.environment["RCT_USE_KMP"] == "1" {
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.")
}

let BUILD_FROM_SOURCE = false

// Removing the legacy TurboModule and component interop layers is opt-in while those
Expand Down
52 changes: 52 additions & 0 deletions packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,18 @@
#import "RCTGradientUtils.h"
#import <React/RCTAnimationUtils.h>
#import <React/RCTConversions.h>
#import <TargetConditionals.h>
#import <react/utils/FloatComparison.h>
#include <optional>
#import <vector>

#if RCT_USE_KMP && TARGET_OS_IOS && !TARGET_OS_MACCATALYST
#define RCT_GRADIENT_USE_KMP 1
#import <ReactNativeShared/ReactNativeShared.h>
#else
#define RCT_GRADIENT_USE_KMP 0
#endif

using namespace facebook::react;

namespace {
Expand Down Expand Up @@ -156,6 +164,45 @@ CGSize calculateMultipliers(CGSize bounds)
return std::nullopt;
}

#if RCT_GRADIENT_USE_KMP
static std::vector<ProcessedColorStop> resolveSharedColorStops(
const std::vector<ColorStop> &colorStops,
CGFloat gradientLineLength)
{
NSMutableArray<RNSGradientStopInput *> *inputs = [NSMutableArray arrayWithCapacity:colorStops.size()];
for (const auto &stop : colorStops) {
auto position = resolveColorStopPosition(stop.position, gradientLineLength);
RNSDouble *boxedPosition = position.has_value() ? [RNSDouble numberWithDouble:position.value()] : nil;
auto input = [[RNSGradientStopInput alloc] initWithPosition:boxedPosition hasColor:static_cast<bool>(stop.color)];
[inputs addObject:input];
}

auto resolved = [RNSGradientStops.shared resolveStops:inputs epsilon:kDefaultEpsilon useDoublePrecision:YES];
std::vector<ProcessedColorStop> result;
result.reserve(resolved.count);
NSArray<NSNumber *> *inputRange = @[ @0.0, @1.0 ];
for (RNSResolvedGradientStop *stop in resolved) {
const auto &leftColor = colorStops[stop.leftColorIndex].color;
SharedColor color;
if (stop.leftColorIndex == stop.rightColorIndex) {
// Preserve the original native color, including dynamic UIColor behavior.
color = leftColor;
} else if (std::isfinite(stop.weight)) {
const auto &rightColor = colorStops[stop.rightColorIndex].color;
NSArray<UIColor *> *outputRange =
@[ RCTUIColorFromSharedColor(leftColor), RCTUIColorFromSharedColor(rightColor) ];
auto interpolatedColor = RCTInterpolateColorInRange(stop.weight, inputRange, outputRange);
auto alpha = (interpolatedColor >> 24) & 0xFF;
auto red = (interpolatedColor >> 16) & 0xFF;
auto green = (interpolatedColor >> 8) & 0xFF;
auto blue = interpolatedColor & 0xFF;
color = colorFromRGBA(red, green, blue, alpha);
}
result.push_back({.color = color, .position = stop.position});
}
return result;
}
#else
// Spec: https://drafts.csswg.org/css-images-4/#coloring-gradient-line (Refer transition hint section)
// Browsers add 9 intermediate color stops when a transition hint is present
// Algorithm is referred from Blink engine
Expand Down Expand Up @@ -261,12 +308,16 @@ CGSize calculateMultipliers(CGSize bounds)

return colorStops;
}
#endif

@implementation RCTGradientUtils
// https://drafts.csswg.org/css-images-4/#color-stop-fixup
+ (std::vector<ProcessedColorStop>)getFixedColorStops:(const std::vector<ColorStop> &)colorStops
gradientLineLength:(CGFloat)gradientLineLength
{
#if RCT_GRADIENT_USE_KMP
return resolveSharedColorStops(colorStops, gradientLineLength);
#else
if (colorStops.empty()) {
return {};
}
Expand Down Expand Up @@ -334,6 +385,7 @@ @implementation RCTGradientUtils
}
}
return processColorTransitionHints(fixedColorStops);
#endif
}

// CAGradientLayer linear gradient squishes the non-square gradient to square gradient.
Expand Down
19 changes: 18 additions & 1 deletion packages/react-native/React/React-RCTFabric.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ else
end

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

header_search_paths = [
"\"$(PODS_TARGET_SRCROOT)/ReactCommon\"",
Expand Down Expand Up @@ -50,13 +54,26 @@ Pod::Spec.new do |s|
s.module_name = module_name
s.weak_framework = "JavaScriptCore"
s.framework = "MobileCoreServices"
s.pod_target_xcconfig = {
pod_target_xcconfig = {
"HEADER_SEARCH_PATHS" => header_search_paths,
"OTHER_CFLAGS" => "$(inherited) " + new_arch_flags,
"CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard()
}.merge!(ENV['USE_FRAMEWORKS'] != nil ? {
"PUBLIC_HEADERS_FOLDER_PATH" => "#{module_name}.framework/Headers/#{header_dir}"
}: {})
if kmp_enabled
# Catalyst has no Kotlin/Native target. Its build keeps the existing implementation.
s.dependency 'React-KMP'
pod_target_xcconfig.merge!({
'GCC_PREPROCESSOR_DEFINITIONS[sdk=iphoneos*]' => '$(inherited) RCT_USE_KMP=1',
'GCC_PREPROCESSOR_DEFINITIONS[sdk=iphonesimulator*]' => '$(inherited) RCT_USE_KMP=1',
'FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]' => '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"',
'FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]' => '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"',
'OTHER_LDFLAGS[sdk=iphoneos*]' => '$(inherited) -framework ReactNativeShared',
'OTHER_LDFLAGS[sdk=iphonesimulator*]' => '$(inherited) -framework ReactNativeShared',
})
end
s.pod_target_xcconfig = pod_target_xcconfig

s.dependency "React-Core"
s.dependency "React-RCTImage"
Expand Down
6 changes: 6 additions & 0 deletions packages/react-native/ReactAndroid/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,12 @@ tasks.withType<KotlinCompile>().configureEach {
}

dependencies {
// Embed the common Kotlin implementation in react-android's AAR. This keeps published
// consumers on the existing artifact instead of requiring a separate KMP publication.
implementation(
files("$reactNativeRootDir/ReactShared/build/android/react-native-shared.jar")
.builtBy(gradle.includedBuild("react-native-shared").task(":exportAndroidJar"))
)
api(libs.androidx.appcompat)
api(libs.androidx.appcompat.resources)
api(libs.androidx.autofill)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
package com.facebook.react.uimanager.style

import androidx.core.graphics.ColorUtils
import com.facebook.react.uimanager.FloatUtil
import com.facebook.react.shared.GradientStopInput
import com.facebook.react.shared.GradientStops
import com.facebook.react.uimanager.LengthPercentage
import com.facebook.react.uimanager.LengthPercentageType
import com.facebook.react.uimanager.PixelUtil
import kotlin.math.ln

/**
* Represents a color stop in a gradient as specified by the user.
Expand Down Expand Up @@ -68,170 +68,25 @@ internal object ColorStopUtils {
colorStops: List<ColorStop>,
gradientLineLength: Float,
): List<ProcessedColorStop> {
val fixedColorStops = Array<ProcessedColorStop>(colorStops.size) { ProcessedColorStop() }
var hasNullPositions = false
var maxPositionSoFar =
resolveColorStopPosition(colorStops[0].position, gradientLineLength) ?: 0f

for (i in colorStops.indices) {
val colorStop = colorStops[i]
var newPosition = resolveColorStopPosition(colorStop.position, gradientLineLength)

// Step 1:
// If the first color stop does not have a position,
// set its position to 0%. If the last color stop does not have a position,
// set its position to 100%.
newPosition =
newPosition
?: when (i) {
0 -> 0f
colorStops.size - 1 -> 1f
else -> null
}

// Step 2:
// If a color stop or transition hint has a position
// that is less than the specified position of any color stop or transition hint
// before it in the list, set its position to be equal to the
// largest specified position of any color stop or transition hint before it.
if (newPosition != null) {
newPosition = maxOf(newPosition, maxPositionSoFar)
fixedColorStops[i] = ProcessedColorStop(colorStop.color, newPosition)
maxPositionSoFar = newPosition
} else {
hasNullPositions = true
}
}

// Step 3:
// If any color stop still does not have a position,
// then, for each run of adjacent color stops without positions,
// set their positions so that they are evenly spaced between the preceding and
// following color stops with positions.
if (hasNullPositions) {
var lastDefinedIndex = 0
for (i in 1 until fixedColorStops.size) {
val endPosition = fixedColorStops[i].position
val startPosition = fixedColorStops[lastDefinedIndex].position
val unpositionedStops = i - lastDefinedIndex - 1
if (endPosition != null && startPosition != null && unpositionedStops > 0) {
val increment = (endPosition - startPosition) / (unpositionedStops + 1)
for (j in 1..unpositionedStops) {
fixedColorStops[lastDefinedIndex + j] =
ProcessedColorStop(
colorStops[lastDefinedIndex + j].color,
startPosition + increment * j,
)
}
lastDefinedIndex = i
} else if (endPosition != null) {
// Current stop has a defined position but there are no unpositioned
// stops between lastDefinedIndex and i. Still need to advance
// lastDefinedIndex so that subsequent interpolation uses the
// correct start point instead of stale data.
lastDefinedIndex = i
val inputs =
colorStops.map { stop ->
GradientStopInput(
resolveColorStopPosition(stop.position, gradientLineLength)?.toDouble(),
stop.color != null,
)
}
}
}

return processColorTransitionHints(fixedColorStops)
}

// Spec: https://drafts.csswg.org/css-images-4/#coloring-gradient-line (Refer transition hint
// section)
// Browsers add 9 intermediate color stops when a transition hint is present
// Algorithm is referred from Blink engine
// [source](https://github.com/chromium/chromium/blob/a296b1bad6dc1ed9d751b7528f7ca2134227b828/third_party/blink/renderer/core/css/css_gradient_value.cc#L240).
private fun processColorTransitionHints(
originalStops: Array<ProcessedColorStop>,
): List<ProcessedColorStop> {
val colorStops = originalStops.toMutableList()
var indexOffset = 0

for (i in 1 until originalStops.size - 1) {
// Skip if not a color hint
if (originalStops[i].color != null) {
continue
}

val x = i + indexOffset
if (x < 1) {
continue
}

val offsetLeft = colorStops[x - 1].position
val offsetRight = colorStops[x + 1].position
val offset = colorStops[x].position
if (offsetLeft == null || offsetRight == null || offset == null) {
continue
}
val leftDist = offset - offsetLeft
val rightDist = offsetRight - offset
val totalDist = offsetRight - offsetLeft
val leftColor = colorStops[x - 1].color
val rightColor = colorStops[x + 1].color

if (FloatUtil.floatsEqual(leftDist, rightDist)) {
colorStops.removeAt(x)
--indexOffset
continue
}

if (FloatUtil.floatsEqual(leftDist, 0f)) {
colorStops[x].color = rightColor
continue
}

if (FloatUtil.floatsEqual(rightDist, 0f)) {
colorStops[x].color = leftColor
continue
}

val newStops = ArrayList<ProcessedColorStop>(9)

// Position the new color stops
if (leftDist > rightDist) {
for (y in 0..6) {
newStops.add(ProcessedColorStop(null, offsetLeft + leftDist * ((7f + y) / 13f)))
}
newStops.add(ProcessedColorStop(null, offset + rightDist * (1f / 3f)))
newStops.add(ProcessedColorStop(null, offset + rightDist * (2f / 3f)))
} else {
newStops.add(ProcessedColorStop(null, offsetLeft + leftDist * (1f / 3f)))
newStops.add(ProcessedColorStop(null, offsetLeft + leftDist * (2f / 3f)))
for (y in 0..6) {
newStops.add(ProcessedColorStop(null, offset + rightDist * (y / 13f)))
}
}

// Calculate colors for the new stops
val hintRelativeOffset = leftDist / totalDist
val logRatio = ln(0.5) / ln(hintRelativeOffset)

for (newStop in newStops) {
if (newStop.position == null) {
continue
}
val pointRelativeOffset = (newStop.position - offsetLeft) / totalDist
val weighting = Math.pow(pointRelativeOffset.toDouble(), logRatio).toFloat()

if (!weighting.isFinite() || weighting.isNaN()) {
continue
}

// Interpolate color using the calculated weighting
leftColor?.let { left ->
rightColor?.let { right -> newStop.color = ColorUtils.blendARGB(left, right, weighting) }
}
}

// Replace the color hint with new color stops
colorStops.removeAt(x)
colorStops.addAll(x, newStops)
indexOffset += 8
return GradientStops.resolve(inputs, epsilon = .00001f.toDouble()).map { stop ->
val leftColor = colorStops[stop.leftColorIndex].color
val rightColor = colorStops[stop.rightColorIndex].color
val weighting = stop.weight.toFloat()
val color =
when {
stop.leftColorIndex == stop.rightColorIndex -> leftColor
leftColor == null || rightColor == null || !weighting.isFinite() -> null
else -> ColorUtils.blendARGB(leftColor, rightColor, weighting)
}
ProcessedColorStop(color, stop.position.toFloat())
}

return colorStops
}

private fun resolveColorStopPosition(
Expand Down
Loading