facebook/react-native

ListView (or perhaps ScrollView) disappears after running in background for several minutes

nihgwu opened this issue Β· 89 comments

I'm using the ScrollView as a ViewPager and placing ListView in it just like the F8 app does, and since I upgrade the RN to 0.29 and now 0.30RC0, I'm persecuted by this problem:

navigate to the detail view from the list view ,then press the home button to make the app running in background, and then wait for several minutes to wake up the app, navigate back to the list view, the ListView (or perhaps or outer ScrollView) just disappears until I scroll it vertically or horizontally ( tap has no effect )

I have no idea what happened as it's OK before RN0.29, I've tested on all the 0.29 versions and the newest 0.30RC version they are all the same.

here is the demo:
listview

I tried to revert the commit 329c716 manually, and now the issue occurs every time, no need for background waiting
listview1

@nicklockwood will you have any time to check it? I'm trying to solve this issue to summit the new version to App Store, thanks

I'll try to revert another commit 1048e5d

still the save after turn off removeClippedSubviews, the ScrollView went to white until scroll

I'm encountering a similar problem after migrating RN from 0.28 to 0.29. I have a ListView off-screen which gets updated (render is called) due to an action occurring in another view. When navigating back to the ListView all items are invisible until I scroll the View up or down. The items seem to re-appear from the top of the screen.

Edit: Turning off removeClippedSubviews solves the problem for me as mentioned here.

We had a similar problem with a ListView on a inactive route which did not render correctly. removeClippedSubviews={false} did the trick. But I didn't find out what was changed between 0.25.1 and 0.29.0 that could relate to this

Maybe this one (329c716) or this one (1fcd73f) can be related?

cc @nicklockwood @majak

@mroswald none of the two commits but 1048e5d I've reverted it and the issue is gone

We have this same problem too. It usually happens when popping navigation back to an existing listview after the app was in the background. Can confirm that the problem started to occur after 0.28, in 0.29 and still in 0.30.

majak commented

Are you able to see this issue on simulator, or does it seem like device only?

@majak The problem exists both in iOS simulator and on real device. It's reliable reproducible.

BTW, turning off removeClippedSubviews only fixed the issue i've described in my first comment, but doesn't fixed the exact situation:
scrollview(listview) -> navigate to detailview -> goto background for several minutes -> active to detailview -> navigate back to scrollview(listview) -> scrollview goes to blank
there should be something wrong with 1048e5d

Same here, after popping route and going to ListView, it stays empty until you interact with it.

majak commented

Which navigator are you using?
A simple project with this issue would be invaluable. I wasn't able to repro it so far, so looks like I'm missing something.

ExNavigator, not sure if I can set up a reproducible example, but I could try to set up screen share and give you the control so you can place breakpoints here and there ;)

I can confirm i have seen this as well. Also using ExNavigator.

If I won't manage to fix it, I might swizzle that method (in order to revert Nick changes) as I do really need to ship my app soon. But happy to help in anyway possible

majak commented

Oh cool, knowing it happens in environment with ExNavigator sounds helpful. Thanks!

@grabbou that method doesn't even exist in rn 0.30.0.rc.0 or master anymore. @nicklockwood refactored everything shortly after that commit with 46c02b6, so it's even more obscure to manually revert back the offending changes.

Anyone have suggestions on how to fix this in latest?

@grabbou I have to and already revert Nick's commit 1048e5d to release my app, and it works well as before till now, (BTW I'm using 0.30RC0 will this commit reverted)
so is there anyone else in core team will fix this issue ASAP since @nicklockwood has been inactive for a long time

Hey @nihgwu can you share your diff on how you reverted 1048e5d on 0.30.0.rc.0? Would be extremely helpful to me and others facing the same problem.

@sjmueller The following is my local version of React/Views/RCTView.m in react-native from L313-L387

- (void)mountOrUnmountSubview:(UIView *)view withClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView
{
  if (view.clipsToBounds) {

    // View has cliping enabled, so we can easily test if it is partially
    // or completely within the clipRect, and mount or unmount it accordingly

    if (!CGRectIsEmpty(CGRectIntersection(clipRect, view.frame))) {

      // View is at least partially visible, so remount it if unmounted
      if (view.superview == nil) {
        [self remountSubview:view];
      }

      // Then test its subviews
      if (CGRectContainsRect(clipRect, view.frame)) {
        [view react_remountAllSubviews];
      } else {
        [view react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
      }

    } else if (view.superview) {

      // View is completely outside the clipRect, so unmount it
      [view removeFromSuperview];
    }

  } else {

    // View has clipping disabled, so there's no way to tell if it has
    // any visible subviews without an expensive recursive test, so we'll
    // just add it.

    if (view.superview == nil) {
      [self remountSubview:view];
    }

    // Check if subviews need to be mounted/unmounted
    [view react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
  }
}

- (void)react_updateClippedSubviewsWithClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView
{
  // TODO (#5906496): for scrollviews (the primary use-case) we could
  // optimize this by only doing a range check along the scroll axis,
  // instead of comparing the whole frame

  if (_reactSubviews == nil) {
    // Use default behavior if unmounting is disabled
    return [super react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
  }

  if (_reactSubviews.count == 0) {
    // Do nothing if we have no subviews
    return;
  }

  if (CGSizeEqualToSize(self.bounds.size, CGSizeZero)) {
    // Do nothing if layout hasn't happened yet
    return;
  }

  // Convert clipping rect to local coordinates
  clipRect = [clipView convertRect:clipRect toView:self];
  clipView = self;
  if (self.clipsToBounds) {
    clipRect = CGRectIntersection(clipRect, self.bounds);
  }

  // Mount / unmount views
  for (UIView *view in _reactSubviews) {
    [self mountOrUnmountSubview:view withClipRect:clipRect relativeToView:clipView];
  }
}

Thanks for sharing @nihgwu, however I don't think you are on RN 0.30. As you can see below, the 0.30 lines don't match up with yours:
https://github.com/facebook/react-native/blob/v0.30.0-rc.0/React/Views/RCTView.m#L313-L387

Looks like your local version was before the refactor in 46c02b6!

@sjmueller Sorry I didn't notice there is another commit after 1048e5d, BTW I'm using RN0.30RC0 😊

Well I've double checked and that my fault not paste all the changed code, the line numbers is for my local vertion, the later commits changed the code line numbers

/**
 * Copyright (c) 2015-present, Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 */

#import "RCTView.h"

#import "RCTAutoInsetsProtocol.h"
#import "RCTBorderDrawing.h"
#import "RCTConvert.h"
#import "RCTLog.h"
#import "RCTUtils.h"
#import "UIView+React.h"

@implementation UIView (RCTViewUnmounting)

- (void)react_remountAllSubviews
{
  // Normal views don't support unmounting, so all
  // this does is forward message to our subviews,
  // in case any of those do support it

  for (UIView *subview in self.subviews) {
    [subview react_remountAllSubviews];
  }
}

- (void)react_updateClippedSubviewsWithClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView
{
  // Even though we don't support subview unmounting
  // we do support clipsToBounds, so if that's enabled
  // we'll update the clipping

  if (self.clipsToBounds && self.subviews.count > 0) {
    clipRect = [clipView convertRect:clipRect toView:self];
    clipRect = CGRectIntersection(clipRect, self.bounds);
    clipView = self;
  }

  // Normal views don't support unmounting, so all
  // this does is forward message to our subviews,
  // in case any of those do support it

  for (UIView *subview in self.subviews) {
    [subview react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
  }
}

- (UIView *)react_findClipView
{
  UIView *testView = self;
  UIView *clipView = nil;
  CGRect clipRect = self.bounds;
  // We will only look for a clipping view up the view hierarchy until we hit the root view.
  BOOL passedRootView = NO;
  while (testView && !passedRootView) {
    if (testView.clipsToBounds) {
      if (clipView) {
        CGRect testRect = [clipView convertRect:clipRect toView:testView];
        if (!CGRectContainsRect(testView.bounds, testRect)) {
          clipView = testView;
          clipRect = CGRectIntersection(testView.bounds, testRect);
        }
      } else {
        clipView = testView;
        clipRect = [self convertRect:self.bounds toView:clipView];
      }
    }
    if ([testView isReactRootView]) {
      passedRootView = YES;
    }
    testView = testView.superview;
  }
  return clipView ?: self.window;
}

@end

static NSString *RCTRecursiveAccessibilityLabel(UIView *view)
{
  NSMutableString *str = [NSMutableString stringWithString:@""];
  for (UIView *subview in view.subviews) {
    NSString *label = subview.accessibilityLabel;
    if (label) {
      [str appendString:@" "];
      [str appendString:label];
    } else {
      [str appendString:RCTRecursiveAccessibilityLabel(subview)];
    }
  }
  return str;
}

@implementation RCTView
{
  NSMutableArray<UIView *> *_reactSubviews;
  UIColor *_backgroundColor;
}

- (instancetype)initWithFrame:(CGRect)frame
{
  if ((self = [super initWithFrame:frame])) {
    _borderWidth = -1;
    _borderTopWidth = -1;
    _borderRightWidth = -1;
    _borderBottomWidth = -1;
    _borderLeftWidth = -1;
    _borderTopLeftRadius = -1;
    _borderTopRightRadius = -1;
    _borderBottomLeftRadius = -1;
    _borderBottomRightRadius = -1;
    _borderStyle = RCTBorderStyleSolid;
    _hitTestEdgeInsets = UIEdgeInsetsZero;

    _backgroundColor = super.backgroundColor;
  }

  return self;
}

RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:unused)

- (NSString *)accessibilityLabel
{
  if (super.accessibilityLabel) {
    return super.accessibilityLabel;
  }
  return RCTRecursiveAccessibilityLabel(self);
}

- (void)setPointerEvents:(RCTPointerEvents)pointerEvents
{
  _pointerEvents = pointerEvents;
  self.userInteractionEnabled = (pointerEvents != RCTPointerEventsNone);
  if (pointerEvents == RCTPointerEventsBoxNone) {
    self.accessibilityViewIsModal = NO;
  }
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
  BOOL canReceiveTouchEvents = ([self isUserInteractionEnabled] && ![self isHidden]);
  if(!canReceiveTouchEvents) {
    return nil;
  }

  // `hitSubview` is the topmost subview which was hit. The hit point can
  // be outside the bounds of `view` (e.g., if -clipsToBounds is NO).
  UIView *hitSubview = nil;
  BOOL isPointInside = [self pointInside:point withEvent:event];
  BOOL needsHitSubview = !(_pointerEvents == RCTPointerEventsNone || _pointerEvents == RCTPointerEventsBoxOnly);
  if (needsHitSubview && (![self clipsToBounds] || isPointInside)) {
    // The default behaviour of UIKit is that if a view does not contain a point,
    // then no subviews will be returned from hit testing, even if they contain
    // the hit point. By doing hit testing directly on the subviews, we bypass
    // the strict containment policy (i.e., UIKit guarantees that every ancestor
    // of the hit view will return YES from -pointInside:withEvent:). See:
    //  - https://developer.apple.com/library/ios/qa/qa2013/qa1812.html
    for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
      CGPoint convertedPoint = [subview convertPoint:point fromView:self];
      hitSubview = [subview hitTest:convertedPoint withEvent:event];
      if (hitSubview != nil) {
        break;
      }
    }
  }

  UIView *hitView = (isPointInside ? self : nil);

  switch (_pointerEvents) {
    case RCTPointerEventsNone:
      return nil;
    case RCTPointerEventsUnspecified:
      return hitSubview ?: hitView;
    case RCTPointerEventsBoxOnly:
      return hitView;
    case RCTPointerEventsBoxNone:
      return hitSubview;
    default:
      RCTLogError(@"Invalid pointer-events specified %zd on %@", _pointerEvents, self);
      return hitSubview ?: hitView;
  }
}

- (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);
}

- (BOOL)accessibilityActivate
{
  if (_onAccessibilityTap) {
    _onAccessibilityTap(nil);
    return YES;
  } else {
    return NO;
  }
}

- (BOOL)accessibilityPerformMagicTap
{
  if (_onMagicTap) {
    _onMagicTap(nil);
    return YES;
  } else {
    return NO;
  }
}

- (NSString *)description
{
  NSString *superDescription = super.description;
  NSRange semicolonRange = [superDescription rangeOfString:@";"];
  NSString *replacement = [NSString stringWithFormat:@"; reactTag: %@;", self.reactTag];
  return [superDescription stringByReplacingCharactersInRange:semicolonRange withString:replacement];
}

#pragma mark - Statics for dealing with layoutGuides

+ (void)autoAdjustInsetsForView:(UIView<RCTAutoInsetsProtocol> *)parentView
                 withScrollView:(UIScrollView *)scrollView
                   updateOffset:(BOOL)updateOffset
{
  UIEdgeInsets baseInset = parentView.contentInset;
  CGFloat previousInsetTop = scrollView.contentInset.top;
  CGPoint contentOffset = scrollView.contentOffset;

  if (parentView.automaticallyAdjustContentInsets) {
    UIEdgeInsets autoInset = [self contentInsetsForView:parentView];
    baseInset.top += autoInset.top;
    baseInset.bottom += autoInset.bottom;
    baseInset.left += autoInset.left;
    baseInset.right += autoInset.right;
  }
  scrollView.contentInset = baseInset;
  scrollView.scrollIndicatorInsets = baseInset;

  if (updateOffset) {
    // If we're adjusting the top inset, then let's also adjust the contentOffset so that the view
    // elements above the top guide do not cover the content.
    // This is generally only needed when your views are initially laid out, for
    // manual changes to contentOffset, you can optionally disable this step
    CGFloat currentInsetTop = scrollView.contentInset.top;
    if (currentInsetTop != previousInsetTop) {
      contentOffset.y -= (currentInsetTop - previousInsetTop);
      scrollView.contentOffset = contentOffset;
    }
  }
}

+ (UIEdgeInsets)contentInsetsForView:(UIView *)view
{
  while (view) {
    UIViewController *controller = view.reactViewController;
    if (controller) {
      return (UIEdgeInsets){
        controller.topLayoutGuide.length, 0,
        controller.bottomLayoutGuide.length, 0
      };
    }
    view = view.superview;
  }
  return UIEdgeInsetsZero;
}

#pragma mark - View unmounting

- (void)react_remountAllSubviews
{
  if (_reactSubviews) {
    NSUInteger index = 0;
    for (UIView *view in _reactSubviews) {
      if (view.superview != self) {
        if (index < self.subviews.count) {
          [self insertSubview:view atIndex:index];
        } else {
          [self addSubview:view];
        }
        [view react_remountAllSubviews];
      }
      index++;
    }
  } else {
    // If react_subviews is nil, we must already be showing all subviews
    [super react_remountAllSubviews];
  }
}

- (void)remountSubview:(UIView *)view
{
  // Calculate insertion index for view
  NSInteger index = 0;
  for (UIView *subview in _reactSubviews) {
    if (subview == view) {
      [self insertSubview:view atIndex:index];
      break;
    }
    if (subview.superview) {
      // View is mounted, so bump the index
      index++;
    }
  }
}

- (void)mountOrUnmountSubview:(UIView *)view withClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView
{
  if (view.clipsToBounds) {

    // View has cliping enabled, so we can easily test if it is partially
    // or completely within the clipRect, and mount or unmount it accordingly

    if (!CGRectIsEmpty(CGRectIntersection(clipRect, view.frame))) {

      // View is at least partially visible, so remount it if unmounted
      if (view.superview == nil) {
        [self remountSubview:view];
      }

      // Then test its subviews
      if (CGRectContainsRect(clipRect, view.frame)) {
        [view react_remountAllSubviews];
      } else {
        [view react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
      }

    } else if (view.superview) {

      // View is completely outside the clipRect, so unmount it
      [view removeFromSuperview];
    }

  } else {

    // View has clipping disabled, so there's no way to tell if it has
    // any visible subviews without an expensive recursive test, so we'll
    // just add it.

    if (view.superview == nil) {
      [self remountSubview:view];
    }

    // Check if subviews need to be mounted/unmounted
    [view react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
  }
}

- (void)react_updateClippedSubviewsWithClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView
{
  // TODO (#5906496): for scrollviews (the primary use-case) we could
  // optimize this by only doing a range check along the scroll axis,
  // instead of comparing the whole frame

  if (_reactSubviews == nil) {
    // Use default behavior if unmounting is disabled
    return [super react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
  }

  if (_reactSubviews.count == 0) {
    // Do nothing if we have no subviews
    return;
  }

  if (CGSizeEqualToSize(self.bounds.size, CGSizeZero)) {
    // Do nothing if layout hasn't happened yet
    return;
  }

  // Convert clipping rect to local coordinates
  clipRect = [clipView convertRect:clipRect toView:self];
  clipView = self;
  if (self.clipsToBounds) {
    clipRect = CGRectIntersection(clipRect, self.bounds);
  }

  // Mount / unmount views
  for (UIView *view in _reactSubviews) {
    [self mountOrUnmountSubview:view withClipRect:clipRect relativeToView:clipView];
  }
}

- (void)setRemoveClippedSubviews:(BOOL)removeClippedSubviews
{
  if (removeClippedSubviews && !_reactSubviews) {
    _reactSubviews = [self.subviews mutableCopy];
  } else if (!removeClippedSubviews && _reactSubviews) {
    [self react_remountAllSubviews];
    _reactSubviews = nil;
  }
}

- (BOOL)removeClippedSubviews
{
  return _reactSubviews != nil;
}

- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
{
  if (_reactSubviews == nil) {
    [self insertSubview:view atIndex:atIndex];
  } else {
    [_reactSubviews insertObject:view atIndex:atIndex];

    // Find a suitable view to use for clipping
    UIView *clipView = [self react_findClipView];
    if (clipView) {

      // If possible, don't add subviews if they are clipped
      [self mountOrUnmountSubview:view withClipRect:clipView.bounds relativeToView:clipView];

    } else {

      // Fallback if we can't find a suitable clipView
      [self remountSubview:view];
    }
  }
}

- (void)removeReactSubview:(UIView *)subview
{
  [_reactSubviews removeObject:subview];
  [subview removeFromSuperview];
}

- (NSArray<UIView *> *)reactSubviews
{
  // The _reactSubviews array is only used when we have hidden
  // offscreen views. If _reactSubviews is nil, we can assume
  // that [self reactSubviews] and [self subviews] are the same

  return _reactSubviews ?: self.subviews;
}

- (void)updateClippedSubviews
{
  // Find a suitable view to use for clipping
  UIView *clipView = [self react_findClipView];
  if (clipView) {
    [self react_updateClippedSubviewsWithClipRect:clipView.bounds relativeToView:clipView];
  }
}

- (void)layoutSubviews
{
  // TODO (#5906496): this a nasty performance drain, but necessary
  // to prevent gaps appearing when the loading spinner disappears.
  // We might be able to fix this another way by triggering a call
  // to updateClippedSubviews manually after loading

  [super layoutSubviews];

  if (_reactSubviews) {
    [self updateClippedSubviews];
  }
}

#pragma mark - Borders

- (UIColor *)backgroundColor
{
  return _backgroundColor;
}

- (void)setBackgroundColor:(UIColor *)backgroundColor
{
  if ([_backgroundColor isEqual:backgroundColor]) {
    return;
  }

  _backgroundColor = backgroundColor;
  [self.layer setNeedsDisplay];
}

- (UIEdgeInsets)bordersAsInsets
{
  const CGFloat borderWidth = MAX(0, _borderWidth);

  return (UIEdgeInsets) {
    _borderTopWidth >= 0 ? _borderTopWidth : borderWidth,
    _borderLeftWidth >= 0 ? _borderLeftWidth : borderWidth,
    _borderBottomWidth >= 0 ? _borderBottomWidth : borderWidth,
    _borderRightWidth  >= 0 ? _borderRightWidth : borderWidth,
  };
}

- (RCTCornerRadii)cornerRadii
{
  // Get corner radii
  const CGFloat radius = MAX(0, _borderRadius);
  const CGFloat topLeftRadius = _borderTopLeftRadius >= 0 ? _borderTopLeftRadius : radius;
  const CGFloat topRightRadius = _borderTopRightRadius >= 0 ? _borderTopRightRadius : radius;
  const CGFloat bottomLeftRadius = _borderBottomLeftRadius >= 0 ? _borderBottomLeftRadius : radius;
  const CGFloat bottomRightRadius = _borderBottomRightRadius >= 0 ? _borderBottomRightRadius : radius;

  // Get scale factors required to prevent radii from overlapping
  const CGSize size = self.bounds.size;
  const CGFloat topScaleFactor = RCTZeroIfNaN(MIN(1, size.width / (topLeftRadius + topRightRadius)));
  const CGFloat bottomScaleFactor = RCTZeroIfNaN(MIN(1, size.width / (bottomLeftRadius + bottomRightRadius)));
  const CGFloat rightScaleFactor = RCTZeroIfNaN(MIN(1, size.height / (topRightRadius + bottomRightRadius)));
  const CGFloat leftScaleFactor = RCTZeroIfNaN(MIN(1, size.height / (topLeftRadius + bottomLeftRadius)));

  // Return scaled radii
  return (RCTCornerRadii){
    topLeftRadius * MIN(topScaleFactor, leftScaleFactor),
    topRightRadius * MIN(topScaleFactor, rightScaleFactor),
    bottomLeftRadius * MIN(bottomScaleFactor, leftScaleFactor),
    bottomRightRadius * MIN(bottomScaleFactor, rightScaleFactor),
  };
}

- (RCTBorderColors)borderColors
{
  return (RCTBorderColors){
    _borderTopColor ?: _borderColor,
    _borderLeftColor ?: _borderColor,
    _borderBottomColor ?: _borderColor,
    _borderRightColor ?: _borderColor,
  };
}

- (void)reactSetFrame:(CGRect)frame
{
  // If frame is zero, or below the threshold where the border radii can
  // be rendered as a stretchable image, we'll need to re-render.
  // TODO: detect up-front if re-rendering is necessary
  CGSize oldSize = self.bounds.size;
  [super reactSetFrame:frame];
  if (!CGSizeEqualToSize(self.bounds.size, oldSize)) {
    [self.layer setNeedsDisplay];
  }
}

- (void)displayLayer:(CALayer *)layer
{
  if (CGSizeEqualToSize(layer.bounds.size, CGSizeZero)) {
    return;
  }

  RCTUpdateShadowPathForView(self);

  const RCTCornerRadii cornerRadii = [self cornerRadii];
  const UIEdgeInsets borderInsets = [self bordersAsInsets];
  const RCTBorderColors borderColors = [self borderColors];

  BOOL useIOSBorderRendering =
  !RCTRunningInTestEnvironment() &&
  RCTCornerRadiiAreEqual(cornerRadii) &&
  RCTBorderInsetsAreEqual(borderInsets) &&
  RCTBorderColorsAreEqual(borderColors) &&
  _borderStyle == RCTBorderStyleSolid &&

  // iOS draws borders in front of the content whereas CSS draws them behind
  // the content. For this reason, only use iOS border drawing when clipping
  // or when the border is hidden.

  (borderInsets.top == 0 || (borderColors.top && CGColorGetAlpha(borderColors.top) == 0) || self.clipsToBounds);

  // iOS clips to the outside of the border, but CSS clips to the inside. To
  // solve this, we'll need to add a container view inside the main view to
  // correctly clip the subviews.

  if (useIOSBorderRendering) {
    layer.cornerRadius = cornerRadii.topLeft;
    layer.borderColor = borderColors.left;
    layer.borderWidth = borderInsets.left;
    layer.backgroundColor = _backgroundColor.CGColor;
    layer.contents = nil;
    layer.needsDisplayOnBoundsChange = NO;
    layer.mask = nil;
    return;
  }

  UIImage *image = RCTGetBorderImage(_borderStyle,
                                     layer.bounds.size,
                                     cornerRadii,
                                     borderInsets,
                                     borderColors,
                                     _backgroundColor.CGColor,
                                     self.clipsToBounds);

  layer.backgroundColor = NULL;

  if (image == nil) {
    layer.contents = nil;
    layer.needsDisplayOnBoundsChange = NO;
    return;
  }

  CGRect contentsCenter = ({
    CGSize size = image.size;
    UIEdgeInsets insets = image.capInsets;
    CGRectMake(
               insets.left / size.width,
               insets.top / size.height,
               1.0 / size.width,
               1.0 / size.height
               );
  });

  if (RCTRunningInTestEnvironment()) {
    const CGSize size = self.bounds.size;
    UIGraphicsBeginImageContextWithOptions(size, NO, image.scale);
    [image drawInRect:(CGRect){CGPointZero, size}];
    image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    contentsCenter = CGRectMake(0, 0, 1, 1);
  }

  layer.contents = (id)image.CGImage;
  layer.contentsScale = image.scale;
  layer.needsDisplayOnBoundsChange = YES;
  layer.magnificationFilter = kCAFilterNearest;

  const BOOL isResizable = !UIEdgeInsetsEqualToEdgeInsets(image.capInsets, UIEdgeInsetsZero);
  if (isResizable) {
    layer.contentsCenter = contentsCenter;
  } else {
    layer.contentsCenter = CGRectMake(0.0, 0.0, 1.0, 1.0);
  }

  [self updateClippingForLayer:layer];
}

static BOOL RCTLayerHasShadow(CALayer *layer)
{
  return layer.shadowOpacity * CGColorGetAlpha(layer.shadowColor) > 0;
}

- (void)reactSetInheritedBackgroundColor:(UIColor *)inheritedBackgroundColor
{
  // Inherit background color if a shadow has been set, as an optimization
  if (RCTLayerHasShadow(self.layer)) {
    self.backgroundColor = inheritedBackgroundColor;
  }
}

static void RCTUpdateShadowPathForView(RCTView *view)
{
  if (RCTLayerHasShadow(view.layer)) {
    if (CGColorGetAlpha(view.backgroundColor.CGColor) > 0.999) {

      // If view has a solid background color, calculate shadow path from border
      const RCTCornerRadii cornerRadii = [view cornerRadii];
      const RCTCornerInsets cornerInsets = RCTGetCornerInsets(cornerRadii, UIEdgeInsetsZero);
      CGPathRef shadowPath = RCTPathCreateWithRoundedRect(view.bounds, cornerInsets, NULL);
      view.layer.shadowPath = shadowPath;
      CGPathRelease(shadowPath);

    } else {

      // Can't accurately calculate box shadow, so fall back to pixel-based shadow
      view.layer.shadowPath = nil;

      RCTLogWarn(@"View #%@ of type %@ has a shadow set but cannot calculate "
                 "shadow efficiently. Consider setting a background color to "
                 "fix this, or apply the shadow to a more specific component.",
                 view.reactTag, [view class]);
    }
  }
}

- (void)updateClippingForLayer:(CALayer *)layer
{
  CALayer *mask = nil;
  CGFloat cornerRadius = 0;

  if (self.clipsToBounds) {

    const RCTCornerRadii cornerRadii = [self cornerRadii];
    if (RCTCornerRadiiAreEqual(cornerRadii)) {

      cornerRadius = cornerRadii.topLeft;

    } else {

      CAShapeLayer *shapeLayer = [CAShapeLayer layer];
      CGPathRef path = RCTPathCreateWithRoundedRect(self.bounds, RCTGetCornerInsets(cornerRadii, UIEdgeInsetsZero), NULL);
      shapeLayer.path = path;
      CGPathRelease(path);
      mask = shapeLayer;
    }
  }

  layer.cornerRadius = cornerRadius;
  layer.mask = mask;
}

#pragma mark Border Color

#define setBorderColor(side)                                \
- (void)setBorder##side##Color:(CGColorRef)color          \
{                                                         \
if (CGColorEqualToColor(_border##side##Color, color)) { \
return;                                               \
}                                                       \
CGColorRelease(_border##side##Color);                   \
_border##side##Color = CGColorRetain(color);            \
[self.layer setNeedsDisplay];                           \
}

setBorderColor()
setBorderColor(Top)
setBorderColor(Right)
setBorderColor(Bottom)
setBorderColor(Left)

#pragma mark - Border Width

#define setBorderWidth(side)                    \
- (void)setBorder##side##Width:(CGFloat)width \
{                                             \
if (_border##side##Width == width) {        \
return;                                   \
}                                           \
_border##side##Width = width;               \
[self.layer setNeedsDisplay];               \
}

setBorderWidth()
setBorderWidth(Top)
setBorderWidth(Right)
setBorderWidth(Bottom)
setBorderWidth(Left)

#pragma mark - Border Radius

#define setBorderRadius(side)                     \
- (void)setBorder##side##Radius:(CGFloat)radius \
{                                               \
if (_border##side##Radius == radius) {        \
return;                                     \
}                                             \
_border##side##Radius = radius;               \
[self.layer setNeedsDisplay];                 \
}

setBorderRadius()
setBorderRadius(TopLeft)
setBorderRadius(TopRight)
setBorderRadius(BottomLeft)
setBorderRadius(BottomRight)

#pragma mark - Border Style

#define setBorderStyle(side)                           \
- (void)setBorder##side##Style:(RCTBorderStyle)style \
{                                                    \
if (_border##side##Style == style) {               \
return;                                          \
}                                                  \
_border##side##Style = style;                      \
[self.layer setNeedsDisplay];                      \
}

setBorderStyle()

- (void)dealloc
{
  CGColorRelease(_borderColor);
  CGColorRelease(_borderTopColor);
  CGColorRelease(_borderRightColor);
  CGColorRelease(_borderBottomColor);
  CGColorRelease(_borderLeftColor);
}

@end

@sjmueller you will find that I just revert 1048e5d, the other commits changed React/Views/RCTView.m are still working

I have the same issue. Navigate to the detail view from the list view, and then go back, the list view disappeared sometimes. It only shows again after scrolling up/down.

😰

I have this problem without using a navigator at all

+1 i have experienced two versions of this issue:

  1. navigating away from a list and back, then removing elements from it breaks the scroll position on android and it removes the first parts of the list but you can see the end parts of it (similar, maybe not the same)
  2. i have a nested listview and the new listview when rendered is all blank until i scroll (fixed by turning off clipped subviews)

fix would be nice to see

@doctadre replace React/Views/RCTView.m with my pasted code should solve your problem, I've upgrade to RN0.31RC0 and the issue remains and I've solved it by this patch

seems this issue is abandoned...should I make a PR to revert 1048e5d

@majak was looking into this issue. Here is the thread in the contributors group - https://www.facebook.com/groups/reactnativeoss/permalink/1572910656338896/

majak commented

I've talked with @nathanajah and we are working on an updated version of clipping, that is more effective, so we can run it every time there could be a change in which views are clipped.
We expect it should be done this week.

Same problem as @winterbe said, occurred in rn 0.29 ios, with Navigator component.
Now I'm using a js-side workaround. Listening the onWillFocus event in navigator, and manually call ListView's scrollTo when navigating to the page which contains this ListView

majak commented

Update: Our time estimation was wrong. @nathanajah is still working on this.

wish it would be solved in 0.32

This is such a major issue that it makes React Native almost unusable. Wish the turnaround on such serious issues was faster.

@majak any progress on this? still facing it in 0.32RC0

majak commented

Update: Sorry guys, this turned out to have much more corner cases than we expected :/ .
Therefore @nathanajah couldn't finish it before his internship ended. I'm going to continue his work.
I'll hopefully update you with more specific plan soon.

@majak if the upcoming patch can't be done in the short term, why not just revert 1048e5d as this issue is really a bad regression for user experience and has been pending for quite a long time.
I can confirm that with the revert this issue could be resolved without side effects

I've update my app ( https://github.com/nihgwu/NeoReader if you are instead of ) in production with every recent RN releases with my patch, and works pretty well as expected.

For those who are bothered with this issue please try to do this manually, I've pasted the patch here #8607 (comment) , you just need to replace the code with this one

majak commented

I'd like to avoid reverting that diff at this point. It's already been a while since it got in, so things build afterwards could break or regress perf.
I'd suggest applying your patch manually for now.

I would advise against using the removeClippedSubviews "fix" on Android. It has caused my app to freeze on Android. I am displaying a rather large list...

majak commented

[RFC] Subview clipping

Problem

React Native makes it very easy and natural to render views which are not really visible to the user, for example items in a list that were scrolled outside the viewport, or just views that are currently positioned offscreen.

This is not great for two reasons:

  1. We keep decoded images for these invisible views in memory
  2. The iOS render process has to traverse unnecessary large view hierarchies (not as bad as (1), but I don't have numbers)

Existing solution

Currently we deal with this by removing invisible views from their parent view, which effectively removes them from their UIWindow. It helps with (1) since we "empty" images without window and with (2) since these view are no longer in the hierarchy. This optimisation is called view clipping.

Evaluating visibility for all views all the time would be pretty expensive, so we made it opt-in by removeClippedSubviews prop.

I didn't define what exactly a invisible view is. In the current approach, we consider a view to be invisible if it's not contained by any of its ancestor's bounds which has clipping turned on. The UIWindow is always clipping.

Unfortunately, the current implementation of these rules still has multiple issues where visibility can change and we don't clip and unclip views correctly - that’s the reason for #8607.

New proposed solution

A simplification of the definition of what an invisible view is can help us get rid of this category of bugs.

I propose view clipping in React Native should work this way:

If view A has subview clipping turned on, its direct subviews will be removed from the view hierarchy if they are completely outside of A's bounds.

This greatly simplifies evaluation of what should be clipped. Previously, any movement of any view in the path from view that clips to UIWindow could affect visibility. Now a change in a view's position or clipping properties can only affect clipping locally. We believe that this is clipping is sufficient for all of the common scenarios we care about (e.g. list views) while doing as little work as possible.

Would this change break my app?

This could negatively affect your app in two cases:

  1. If you rely on views being clipped because they don't fit screen or don't fit bounds of a view higher up in the hierarchy. We only clip based on direct superview in the new world, so these views would be no longer clipped.
    This would manifest as increased memory usage. Solution here would either involve shrinking your direct clipping view or introducing new clipping views.
  2. If you have views that are outside of bounds of a clipping view higher up in the hierarchy and their absolute position is on screen. Previously, these views would get clipped. Now they won't and would be visible to user.
    This issue would be immediately visible. Introducing a new clipping view as a parent of these problematic views with a right size should be a way to fix this.

It still won't work in 100% of cases but that's ok

React Native has two different view hierarchies, the shadow one and the real UI one.

These two hierarchies are usually the same but the clipping has to be done based on the real UIView hierarchy. The reason for that is that a native view manager can do anything it wants (for example the Modal components is mounted somewhere completely new in the view tree). So in order to cover 100% of cases where a view movement changes view visibility we would have to hook into all frame changes, even for regular UIViews.
There is no nice way to do that for generic UIView, and we think making subview clipping works correctly for RCTView (the implementation of ) will provide the majority of the value. For any view manager doing custom view hierarchy manipulation, we'll provide APIs to trigger view clipping manually.

(thanks to @nathanajah for figuring out what's hard and @javache for helping with the proposal)

majak commented

One very specific example that would be affected by this change is the side by side swipable tables like in the very first post in this thread. Currently we clip all rows in an offscreen table, but we don't add them back when table is move back to viewport = this issue.
In the new world, these rows wouldn't get removed at all, unless there is a superview that covers the screen exactly, contains all the tables and have removeClippedSubviews turned on.

@chrisbiance seems you've make the patch I proposed above, want to know does that fix your problem

@nihgwu Yes, your patch fixed the issue for me

I'm getting:

Exception thrown while executing UI block: RCTRefreshControl may only be managed by a UITableViewController

with both chrisbianca@edf07d8 and #8607 (comment)

(RN 0.32)

I've been trying to work around this by disabling the clipped subview on load, then setting it to true after a second, but it's generating some weird side effects.. It does fix the problem in most cases, but it doesn't keep the scroll position when navigating back to the view in question.

@majak any progress? sorry to bother but it has been a while since you proposed your solution.

I found a workaround that works for my case, hope this could also help someone else.
I had a Scrollview correctly populated on render. On the side I had several buttons and everytime a button was pressed, the ScrollView contents should be changed. My scrollview used to became blank everytime a button was pressed, but if I tried to scroll it anyways, its contents showed up again. I also noticed that it only happened when the ScrollView was scrolled before clicking on a button.

What I did was to call the scrollTo() function just right after the setState() that changed the ScrollView contents.

Hoping to make this clearer:

class myClass  extends Component {
  myCustomEventHandlerFunction(){
    ...
    this.setState({
      ...
    });
    this.refs._my_scrollview.scrollTo({x:0,y:0,animated:false});
    ...
  }

  render(){
    ...
    <TouchableHighlight 
        onPress={this._myCustomEventHandlerFunction.bind(this)}>
                <Text>mybutton</Text>
    </TouchableHighlight>
    ...
    <ScrollView
              centerContent={true}
              horizontal={true}
              style={{flex:1}}
              ref="_my_scrollview"
              >
              {listItems}
        </ScrollView>
    ...
  }
}
majak commented

@nihgwu not much progress yet, I've been on holiday. I'm back now, so you should expect progress on implementing this proposal this week :)

Small thing I've discovered: This proposal is similar to what currently happens on Android, where we don't use window's bounds for clipping.
It means my plan to not use window for clipping on iOS should be safe.

@majak glad to know, wish we can close this issue ASAP

I'm a week into implementing the iOS counterpart of an Android app.

This issue got me pulling almost all of my hair out :)

Made me change like 3 components (swiper, router and some proprietary stuff) trying to figure out what was causing this.

I ended up here by looking at a closed issue (#9868), that referenced this one.

I'll make a habit of scanning issues from now on.

majak commented

Update: Initial implementation done. Next steps: polishing up and going through internal review.

@majak wait for you PR to test πŸ‘

majak commented

We don't have a definitive implementation yet, but this patch should have the same effect as the final version will: https://gist.github.com/majak/b0ee1fb6ca725d2cf810d2f0ab394f2e
Feel free to try it out and let me know if it fixes your issue.

@majak how to apply this patch to my own project?

majak commented

Ah yeah, the paths are not right. Let me try to fix it.

majak commented

Try to put the updated patch in your project folder and run git apply [filename].

ok, now the paths are right, I will report after my test

@majak I've test your patch, and it works great, I think you should send a PR to end it πŸ˜„

and perhaps rebase is needed as a commit for tvOS has changed relevant files

@nihgwu - is there anything I can do to help out?

apply the patch by @majak would be fine, I'm using it in production

majak commented

Unfortunately I had to prioritize other issues over this one. Now, I should have time to finally do the remaining work and merge it in.

alloy commented

@majak Thanks for your work thus far πŸ‘ I’ve included in our app and thus far seen good results.

However, I’m seeing one small issue and I’m wondering if you have your work pushed somewhere publicly (aside from the gist) so that I can collaborate on it?

majak commented

Not pushed in yet, since the current patch as is wouldn't play nice (=clip) with one of our use cases. I'm currently exploring alternatives (clipping recursively or altering our use case).

@majak
Would it be possible for you to update the patch in the gist or even better to provide a repository with the patch? The recent changes on two files (RCTScrollView.m and RCViewManager.m) broke the patch. For now I'm applying it manually, but by doing this updates will be more difficult/work.

majak commented

@JosefRoth I plan to publish the new recursive version this week. Stay tuned!

RE #8607 (comment)

I wonder if we could traverse subviews when overflow: 'visible' is present on the top level row component's style.

@majak any news here, I'm waiting to upgrade to the latest RN πŸ˜„

majak commented

Heads up: we've uncovered the linked solution (625c8cb & co.) doesn't work well with modals, so I've reverted the change on master until we come up with a proper fix 😞

alloy commented

@majak Bummer, was just going to comment that it works well in our app. Good you caught that case, though πŸ‘Œ

Who cares about modal πŸ˜”

@majak for most of the time, your commit works really well, but sometimes when I CMD+R to reload the app, the list becomes blank until I scroll it. I think that is a minor issue I can ignore it, but the described issue in this thread has been there for nearly 10 releases since RN0.29, and turning off removeClippedSubviews doesn't work for me, so I'm waiting for a proper workaround for this, even it's not perfect enough but only it works well

? 2016?11?22??01:53?Martin Kralik <notifications@github.commailto:notifications@github.com> ???

Heads up: we've uncovered the linked solution (625c8cbhttps://github.com/facebook/react-native/commit/625c8cb83c7be01b1d5f646b70f8fb1d4c70a45c & co.) doesn't work well with modals, so I've reverted the change on master until we come up with a proper fix ?

You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHubhttps://github.com//issues/8607#issuecomment-262014664, or mute the threadhttps://github.com/notifications/unsubscribe-auth/ACeY8jW6joUSqeE6_wETe6mS4Kifwct5ks5rAdqegaJpZM4JGSiX.

@majak none cares about modal!!!

@nihgwu I'm still using 0.27 since I saw all these messed up things here.
removeClippedSubviews=true will make everything OK on 0.27. If you're in a hurry, you could use the old version as me.

removeClippedSubviews={false} causes massive performance issue especially on Android with a list of photos, just be aware if you use that as a solution/workaround.

"react": "15.3.2",
"react-native": "0.37.0",

Same problem happen in ListView with gridLayout({flexDriection:row,flexWrap:wrap}).
it can only show 1st column,but not others column.

before scroll:
image
after scroll:
image

and removeClippedSubviews={false} fixed problem.

@majak FYI, I've upgraded to RN0.40RC0 and seems this issue has gone, I thought you reverted all your commits over clip commits, so I'm wrong?

I've check the commit history, seems you've reverted all the related commits, then why I don't get this issue now

majak commented

@nihgwu yes I did revert everything. Could you bisect to figure out why it's working for you now?

@majak Perhaps it's because I'm using Native Animated.event to handle scroll, I'll try to turn off native driver to check.
FYI, I'm sure this issue is gone in my app, I've test for days.

UPDATE: not related to Native Animated.event, I switched to NavigationExperimental from Navigator in this version too, but still not sure if this change makes the issue gone, need more developers to report their status after they upgrade to RN0.40

Hi guys,

I just upgraded to RN0.40. The issue is still present in my app. Unlike @nihgwu I'm still using the old Navigator, not NavigationExperimental.

@majak Are there any new estimates for a solution?

Kind regards
Josef

Closing as ListView is deprecated in 0.43. Use FlatList.

I am having the same issue with FlatList in 0.43 with react-navigation.

Anyone else still having the same issue? Still looking for a solution..

UPDATE:
Adding the below to the FlatList solved the blank rendering until scroll to show items problem:

removeClippedSubviews={false}

I don't know if it's the most efficient solution, but it's the only one I found. If there is a better way to go about this from a performance stand point, please let us know @sahrens

To reproduce the issue:

  1. Scene A has a FlatList of 10 items.
  2. On press of first item, go to Scene B.
  3. Scene B also has a FlatList of some items (even just one is fine to test it though).
  4. Go back to Scene A
  5. Repeat step 2, the list will be empty on Scene B
  6. Scroll up/down in that blank empty list location, the items will show up.

I need sticky Headers so I'm stuck using ListView @hramos. additionally, does setting
removeClippedSubviews ={ false} lead to having my entire list rows render at once?

React Native 0.44.0 (latest) has sticky header support for both iOS and Android in SectionList.

removeClippedSubviews does not affect JavaScript so react rendering is the same and initial load/render time will be unaffected, but disabling it loses the native optimization to pull offscreen views out of the view hierarchy and scroll performance may suffer as a result.

does this problem fix? i occur this at RN 0.41 with listView and navigator.
@nihgwu Do you figure out why it's working for you ?