react-native-screens icon indicating copy to clipboard operation
react-native-screens copied to clipboard

[iOS][SplitView] Switching the color to undefined leaves the blur effect behind sidebar

Open t0maboro opened this issue 2 months ago • 2 comments

Description

We've identified a native-level bug that can be consistently reproduced when the following conditions are met:

  1. The deepest view in the hierarchy is a Text component.
  2. The background color of the view is set to a value with alpha = 0
  3. There is an odd number of parent views between the Text component and the root of SplitViewScreen that have collapsable: false.

When all of the above conditions are true, the blur effect caused by the transparent background does not disappear under the primary column as expected.

Steps to reproduce

  1. Run the example from snack
  2. Click on some pressables - blur is left after unfocusing the element

https://github.com/user-attachments/assets/42049deb-738e-4d8f-8c2a-cfad2dc3d879

Snack or a link to a repository

https://snack.expo.dev/@tomasz.boron/curious-indigo-ramen

Screens version

4.17.1

React Native version

0.82.0

Platforms

iOS

JavaScript runtime

Hermes

Workflow

React Native (without Expo)

Architecture

Fabric (New Architecture)

Build type

Debug mode

Device

iOS simulator

Device model

No response

Acknowledgements

Yes

t0maboro avatar Oct 22 '25 13:10 t0maboro

The issue is reproducible with the native application

#import "ViewController.h"

@interface ViewController ()

@property (nonatomic, strong) NSMutableArray<UIView *> *pressableViews;

@end

@implementation ViewController

#pragma mark - Init

- (instancetype)init {
    self = [super initWithStyle:UISplitViewControllerStyleDoubleColumn];
    if (self) {
        [self configureSplitView];
    }
    return self;
}

- (void)configureSplitView {
    self.pressableViews = [NSMutableArray array];

    UIViewController *primaryVC = [[UIViewController alloc] init];
    primaryVC.view.backgroundColor = UIColor.clearColor;

    UIViewController *secondaryVC = [[UIViewController alloc] init];
    secondaryVC.view.backgroundColor = UIColor.clearColor;

    UIView *containerView = secondaryVC.view;
    UIView *previousView = nil;

    for (NSInteger i = 0; i < 4; i++) {
        UIView *pressableView = [[UIView alloc] init];
        pressableView.translatesAutoresizingMaskIntoConstraints = NO;
        pressableView.backgroundColor = UIColor.clearColor;
        pressableView.userInteractionEnabled = YES;
        [containerView addSubview:pressableView];

        [NSLayoutConstraint activateConstraints:@[
            [pressableView.leadingAnchor constraintEqualToAnchor:containerView.leadingAnchor constant:16],
            [pressableView.trailingAnchor constraintEqualToAnchor:containerView.trailingAnchor constant:-16],
            [pressableView.heightAnchor constraintEqualToConstant:200]
        ]];
        if (previousView == nil) {
            [pressableView.topAnchor constraintEqualToAnchor:containerView.topAnchor constant:24].active = YES;
        } else {
            [pressableView.topAnchor constraintEqualToAnchor:previousView.bottomAnchor constant:16].active = YES;
        }

        UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handlePress:)];
        [pressableView addGestureRecognizer:tapRecognizer];
        [self.pressableViews addObject:pressableView];

//        uncomment to make it work
//
//        UIView *extraView = [[UIView alloc] init];
//        extraView.translatesAutoresizingMaskIntoConstraints = NO;
//        extraView.backgroundColor = [UIColor clearColor];
//        [pressableView addSubview:extraView];
//
//        [NSLayoutConstraint activateConstraints:@[
//            [extraView.leadingAnchor constraintEqualToAnchor:pressableView.leadingAnchor],
//            [extraView.trailingAnchor constraintEqualToAnchor:pressableView.trailingAnchor],
//            [extraView.bottomAnchor constraintEqualToAnchor:pressableView.bottomAnchor],
//            [extraView.heightAnchor constraintEqualToConstant:50]
//        ]];

        previousView = pressableView;
    }

    if (previousView) {
        [NSLayoutConstraint activateConstraints:@[
            [previousView.bottomAnchor constraintLessThanOrEqualToAnchor:containerView.bottomAnchor constant:-24]
        ]];
    }

    [self setViewController:primaryVC forColumn:UISplitViewControllerColumnPrimary];
    [self setViewController:secondaryVC forColumn:UISplitViewControllerColumnSecondary];
    self.preferredDisplayMode = UISplitViewControllerDisplayModeOneBesideSecondary;
    self.preferredSplitBehavior = UISplitViewControllerSplitBehaviorTile;
}

- (void)handlePress:(UITapGestureRecognizer *)gestureRecognizer {
    UIView *pressedView = gestureRecognizer.view;
    if (!pressedView) return;

    for (UIView *view in self.pressableViews) {
        view.backgroundColor = (view == pressedView) ? [UIColor magentaColor] : [UIColor clearColor];
    }
}

@end

t0maboro avatar Oct 22 '25 13:10 t0maboro

The issue occurs on the native side, as noted above, because UIKit incorrectly clears the blur on the appropriate layer. A trigger, such as focusing on the primary column, causes the view to be redrawn. Forcing a view redraw would be a viable workaround, but the correct solution should be applied on the native side. Any actions taken on our side will merely serve as workarounds, similar to those I have implemented natively.

At the moment, we’re unable to provide a valid workaround on the RNScreens side without causing significant performance regressions. However, we can suggest a few potential workarounds on the application side:

Add an artificial non-collapsable wrapper

Before

  <Pressable
    onPress={toggleBgColor}
    style={[styles.row, isActive && styles.activeRow]}>
    <View collapsable={false}>
      <Text>
        {title}
      </Text>
    </View>
  </Pressable>

After

  <Pressable
    onPress={toggleBgColor}
    style={[styles.row, isActive && styles.activeRow]}>
    <View collapsable={false}>
      <View collapsable={false}>    // inserting additional View, the number of non-collapsable views should be even
        <Text>
          {title}
        </Text>
      </View>
    </View>
  </Pressable>

Set a non-zero borderWidth on the view where background changes and blur issues occur

Before

  <Pressable
    onPress={toggleBgColor}
    style={[styles.row, isActive && styles.activeRow]}>
    <View collapsable={false}>
      <Text>
        {title}
      </Text>
    </View>
  </Pressable>

After

  <Pressable
    onPress={toggleBgColor}
    style={[styles.row, isActive && styles.activeRow, {borderWidth: 1, borderColor: 'transparent'}]}> // add transparent border to styles
    <View collapsable={false}>
        <Text>
          {title}
        </Text>
    </View>
  </Pressable>

Use a background color with a low alpha > 0 instead of clearColor, transparent, etc.

Before

  <Pressable
    onPress={toggleBgColor}
    style={[styles.row, isActive && styles.activeRow]}>
    <View collapsable={false}>
      <Text>
        {title}
      </Text>
    </View>
  </Pressable>

After

  <Pressable
    onPress={toggleBgColor}
    style={[styles.row, isActive ? {backgroundColor: '#ff00ffff'} : {backgroundColor: '#ff00ff01'}]}> // add non-zero opacity color for inactive state
    <View collapsable={false}>
        <Text>
          {title}
        </Text>
    </View>
  </Pressable>

Use shouldRasterizeIOS prop on the view setting the backgroundColor

Before

  <Pressable
    onPress={toggleBgColor}
    style={[styles.row, isActive && styles.activeRow]}>
    <View collapsable={false}>
      <Text>
        {title}
      </Text>
    </View>
  </Pressable>

After

  <Pressable
    shouldRasterizeIOS // toggle rasterization to rely on the bitmap from this view level
    onPress={toggleBgColor}
    style={[styles.row, isActive && styles.activeRow]}>
    <View collapsable={false}>
      <Text>
        {title}
      </Text>
    </View>
  </Pressable>

t0maboro avatar Oct 22 '25 13:10 t0maboro