Nick Lockwood b115277d00 Fixed border smearing issue
Summary:
public
The iOS border rendering code did not follow the CSS spec in cases where the sum of adjacent border radii was greater than the width of the view, resulting in drawing glitches such as pixel smear and borders appearing stretched or squashed.

This diff brings our implementation closer to spec-compliance in these cases. I also fixed a longstanding issue with ghostly diagonal lines appearing at the corners due to antialiasing rounding errors!

Fixes

https://github.com/facebook/react-native/issues/1572
https://github.com/facebook/react-native/issues/2089
https://github.com/facebook/react-native/issues/4604

Reviewed By: tadeuzagallo

Differential Revision: D2811249

fb-gh-sync-id: c3dd2721e0a01a432fa4dc78daa05680595edd08
2016-01-07 12:03:17 -08:00

707 lines
21 KiB
Objective-C

/**
* 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;
while (testView) {
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];
}
}
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;
_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)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 = MIN(1, size.width / (topLeftRadius + topRightRadius));
const CGFloat bottomScaleFactor = MIN(1, size.width / (bottomLeftRadius + bottomRightRadius));
const CGFloat rightScaleFactor = MIN(1, size.height / (topRightRadius + bottomRightRadius));
const CGFloat leftScaleFactor = 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)displayLayer:(CALayer *)layer
{
if (CGSizeEqualToSize(layer.bounds.size, CGSizeZero)) {
return;
}
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 || 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];
}
- (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