1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 | #import "RCTView.h"
|
9 |
|
10 | #import "RCTAutoInsetsProtocol.h"
|
11 | #import "RCTBorderDrawing.h"
|
12 | #import "RCTConvert.h"
|
13 | #import "RCTLog.h"
|
14 | #import "RCTUtils.h"
|
15 | #import "UIView+React.h"
|
16 | #import "RCTI18nUtil.h"
|
17 |
|
18 | UIAccessibilityTraits const SwitchAccessibilityTrait = 0x20000000000001;
|
19 |
|
20 | @implementation UIView (RCTViewUnmounting)
|
21 |
|
22 | - (void)react_remountAllSubviews
|
23 | {
|
24 | // Normal views don't support unmounting, so all
|
25 | // this does is forward message to our subviews,
|
26 | // in case any of those do support it
|
27 |
|
28 | for (UIView *subview in self.subviews) {
|
29 | [subview react_remountAllSubviews];
|
30 | }
|
31 | }
|
32 |
|
33 | - (void)react_updateClippedSubviewsWithClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView
|
34 | {
|
35 | // Even though we don't support subview unmounting
|
36 | // we do support clipsToBounds, so if that's enabled
|
37 | // we'll update the clipping
|
38 |
|
39 | if (self.clipsToBounds && self.subviews.count > 0) {
|
40 | clipRect = [clipView convertRect:clipRect toView:self];
|
41 | clipRect = CGRectIntersection(clipRect, self.bounds);
|
42 | clipView = self;
|
43 | }
|
44 |
|
45 | // Normal views don't support unmounting, so all
|
46 | // this does is forward message to our subviews,
|
47 | // in case any of those do support it
|
48 |
|
49 | for (UIView *subview in self.subviews) {
|
50 | [subview react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
|
51 | }
|
52 | }
|
53 |
|
54 | - (UIView *)react_findClipView
|
55 | {
|
56 | UIView *testView = self;
|
57 | UIView *clipView = nil;
|
58 | CGRect clipRect = self.bounds;
|
59 | // We will only look for a clipping view up the view hierarchy until we hit the root view.
|
60 | while (testView) {
|
61 | if (testView.clipsToBounds) {
|
62 | if (clipView) {
|
63 | CGRect testRect = [clipView convertRect:clipRect toView:testView];
|
64 | if (!CGRectContainsRect(testView.bounds, testRect)) {
|
65 | clipView = testView;
|
66 | clipRect = CGRectIntersection(testView.bounds, testRect);
|
67 | }
|
68 | } else {
|
69 | clipView = testView;
|
70 | clipRect = [self convertRect:self.bounds toView:clipView];
|
71 | }
|
72 | }
|
73 | if ([testView isReactRootView]) {
|
74 | break;
|
75 | }
|
76 | testView = testView.superview;
|
77 | }
|
78 | return clipView ?: self.window;
|
79 | }
|
80 |
|
81 | @end
|
82 |
|
83 | static NSString *RCTRecursiveAccessibilityLabel(UIView *view)
|
84 | {
|
85 | NSMutableString *str = [NSMutableString stringWithString:@""];
|
86 | for (UIView *subview in view.subviews) {
|
87 | NSString *label = subview.accessibilityLabel;
|
88 | if (!label) {
|
89 | label = RCTRecursiveAccessibilityLabel(subview);
|
90 | }
|
91 | if (label && label.length > 0) {
|
92 | if (str.length > 0) {
|
93 | [str appendString:@" "];
|
94 | }
|
95 | [str appendString:label];
|
96 | }
|
97 | }
|
98 | return str.length == 0 ? nil : str;
|
99 | }
|
100 |
|
101 | @implementation RCTView
|
102 | {
|
103 | UIColor *_backgroundColor;
|
104 | NSMutableDictionary<NSString *, NSDictionary *> *accessibilityActionsNameMap;
|
105 | NSMutableDictionary<NSString *, NSDictionary *> *accessibilityActionsLabelMap;
|
106 | }
|
107 |
|
108 | - (instancetype)initWithFrame:(CGRect)frame
|
109 | {
|
110 | if ((self = [super initWithFrame:frame])) {
|
111 | _borderWidth = -1;
|
112 | _borderTopWidth = -1;
|
113 | _borderRightWidth = -1;
|
114 | _borderBottomWidth = -1;
|
115 | _borderLeftWidth = -1;
|
116 | _borderStartWidth = -1;
|
117 | _borderEndWidth = -1;
|
118 | _borderTopLeftRadius = -1;
|
119 | _borderTopRightRadius = -1;
|
120 | _borderTopStartRadius = -1;
|
121 | _borderTopEndRadius = -1;
|
122 | _borderBottomLeftRadius = -1;
|
123 | _borderBottomRightRadius = -1;
|
124 | _borderBottomStartRadius = -1;
|
125 | _borderBottomEndRadius = -1;
|
126 | _borderStyle = RCTBorderStyleSolid;
|
127 | _hitTestEdgeInsets = UIEdgeInsetsZero;
|
128 |
|
129 | _backgroundColor = super.backgroundColor;
|
130 | }
|
131 |
|
132 | return self;
|
133 | }
|
134 |
|
135 | RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:unused)
|
136 |
|
137 | - (void)setReactLayoutDirection:(UIUserInterfaceLayoutDirection)layoutDirection
|
138 | {
|
139 | if (_reactLayoutDirection != layoutDirection) {
|
140 | _reactLayoutDirection = layoutDirection;
|
141 | [self.layer setNeedsDisplay];
|
142 | }
|
143 |
|
144 | if ([self respondsToSelector:@selector(setSemanticContentAttribute:)]) {
|
145 | self.semanticContentAttribute =
|
146 | layoutDirection == UIUserInterfaceLayoutDirectionLeftToRight ?
|
147 | UISemanticContentAttributeForceLeftToRight :
|
148 | UISemanticContentAttributeForceRightToLeft;
|
149 | }
|
150 | }
|
151 |
|
152 | - (NSString *)accessibilityLabel
|
153 | {
|
154 | NSString *label = super.accessibilityLabel;
|
155 | if (label) {
|
156 | return label;
|
157 | }
|
158 | return RCTRecursiveAccessibilityLabel(self);
|
159 | }
|
160 |
|
161 | - (NSArray <UIAccessibilityCustomAction *> *)accessibilityCustomActions
|
162 | {
|
163 | if (!self.accessibilityActions.count) {
|
164 | return nil;
|
165 | }
|
166 |
|
167 | accessibilityActionsNameMap = [[NSMutableDictionary alloc] init];
|
168 | accessibilityActionsLabelMap = [[NSMutableDictionary alloc] init];
|
169 | NSMutableArray *actions = [NSMutableArray array];
|
170 | for (NSDictionary *action in self.accessibilityActions) {
|
171 | if (action[@"name"]) {
|
172 | accessibilityActionsNameMap[action[@"name"]] = action;
|
173 | }
|
174 | if (action[@"label"]) {
|
175 | accessibilityActionsLabelMap[action[@"label"]] = action;
|
176 | [actions addObject:[[UIAccessibilityCustomAction alloc] initWithName:action[@"label"]
|
177 | target:self
|
178 | selector:@selector(didActivateAccessibilityCustomAction:)]];
|
179 | }
|
180 | }
|
181 |
|
182 | return [actions copy];
|
183 | }
|
184 |
|
185 | - (BOOL)didActivateAccessibilityCustomAction:(UIAccessibilityCustomAction *)action
|
186 | {
|
187 | if (!_onAccessibilityAction || !accessibilityActionsLabelMap) {
|
188 | return NO;
|
189 | }
|
190 |
|
191 | // iOS defines the name as the localized label, so use our map to convert this back to the non-localized action namne when passing to JS. This allows for standard action names across platforms.
|
192 |
|
193 | NSDictionary *actionObject = accessibilityActionsLabelMap[action.name];
|
194 | if (actionObject) {
|
195 | _onAccessibilityAction(@{
|
196 | @"actionName": actionObject[@"name"],
|
197 | @"actionTarget": self.reactTag
|
198 | });
|
199 | }
|
200 | return YES;
|
201 | }
|
202 |
|
203 | - (NSString *)accessibilityValue
|
204 | {
|
205 | if ((self.accessibilityTraits & SwitchAccessibilityTrait) == SwitchAccessibilityTrait) {
|
206 | for (NSString *state in self.accessibilityState) {
|
207 | id val = self.accessibilityState[state];
|
208 | if (!val) {
|
209 | continue;
|
210 | }
|
211 | if ([state isEqualToString:@"checked"] && [val isKindOfClass:[NSNumber class]]) {
|
212 | return [val boolValue] ? @"1" : @"0";
|
213 | }
|
214 | }
|
215 | }
|
216 | NSMutableArray *valueComponents = [NSMutableArray new];
|
217 | static NSDictionary<NSString *, NSString *> *roleDescriptions = nil;
|
218 | static dispatch_once_t onceToken1;
|
219 | dispatch_once(&onceToken1, ^{
|
220 | roleDescriptions = @{
|
221 | @"alert" : @"alert",
|
222 | @"checkbox" : @"checkbox",
|
223 | @"combobox" : @"combo box",
|
224 | @"menu" : @"menu",
|
225 | @"menubar" : @"menu bar",
|
226 | @"menuitem" : @"menu item",
|
227 | @"progressbar" : @"progress bar",
|
228 | @"radio" : @"radio button",
|
229 | @"radiogroup" : @"radio group",
|
230 | @"scrollbar" : @"scroll bar",
|
231 | @"spinbutton" : @"spin button",
|
232 | @"switch" : @"switch",
|
233 | @"tab" : @"tab",
|
234 | @"tablist" : @"tab list",
|
235 | @"timer" : @"timer",
|
236 | @"toolbar" : @"tool bar",
|
237 | };
|
238 | });
|
239 | static NSDictionary<NSString *, NSString *> *stateDescriptions = nil;
|
240 | static dispatch_once_t onceToken2;
|
241 | dispatch_once(&onceToken2, ^{
|
242 | stateDescriptions = @{
|
243 | @"checked" : @"checked",
|
244 | @"unchecked" : @"not checked",
|
245 | @"busy" : @"busy",
|
246 | @"expanded" : @"expanded",
|
247 | @"collapsed" : @"collapsed",
|
248 | @"mixed": @"mixed",
|
249 | };
|
250 | });
|
251 | NSString *roleDescription = self.accessibilityRole ? roleDescriptions[self.accessibilityRole]: nil;
|
252 | if (roleDescription) {
|
253 | [valueComponents addObject:roleDescription];
|
254 | }
|
255 | for (NSString *state in self.accessibilityState) {
|
256 | id val = self.accessibilityState[state];
|
257 | if (!val) {
|
258 | continue;
|
259 | }
|
260 | if ([state isEqualToString:@"checked"]) {
|
261 | if ([val isKindOfClass:[NSNumber class]]) {
|
262 | [valueComponents addObject:stateDescriptions[[val boolValue] ? @"checked" : @"unchecked"]];
|
263 | } else if ([val isKindOfClass:[NSString class]] && [val isEqualToString:@"mixed"]) {
|
264 | [valueComponents addObject:stateDescriptions[@"mixed"]];
|
265 | }
|
266 | }
|
267 | if ([state isEqualToString:@"expanded"] && [val isKindOfClass:[NSNumber class]]) {
|
268 | [valueComponents addObject:stateDescriptions[[val boolValue] ? @"expanded" : @"collapsed"]];
|
269 | }
|
270 | if ([state isEqualToString:@"busy"] && [val isKindOfClass:[NSNumber class]] && [val boolValue]) {
|
271 | [valueComponents addObject:stateDescriptions[@"busy"]];
|
272 | }
|
273 | }
|
274 |
|
275 | // handle accessibilityValue
|
276 |
|
277 | if (self.accessibilityValueInternal) {
|
278 | id min = self.accessibilityValueInternal[@"min"];
|
279 | id now = self.accessibilityValueInternal[@"now"];
|
280 | id max = self.accessibilityValueInternal[@"max"];
|
281 | id text = self.accessibilityValueInternal[@"text"];
|
282 | if (text && [text isKindOfClass:[NSString class]]) {
|
283 | [valueComponents addObject:text];
|
284 | } else if ([min isKindOfClass:[NSNumber class]] &&
|
285 | [now isKindOfClass:[NSNumber class]] &&
|
286 | [max isKindOfClass:[NSNumber class]] &&
|
287 | ([min intValue] < [max intValue]) &&
|
288 | ([min intValue] <= [now intValue] && [now intValue] <= [max intValue])) {
|
289 | int val = ([now intValue]*100)/([max intValue]-[min intValue]);
|
290 | [valueComponents addObject:[NSString stringWithFormat:@"%d percent", val]];
|
291 | }
|
292 | }
|
293 |
|
294 | if (valueComponents.count > 0) {
|
295 | return [valueComponents componentsJoinedByString:@", "];
|
296 | }
|
297 | return nil;
|
298 | }
|
299 |
|
300 | - (void)setPointerEvents:(RCTPointerEvents)pointerEvents
|
301 | {
|
302 | _pointerEvents = pointerEvents;
|
303 | self.userInteractionEnabled = (pointerEvents != RCTPointerEventsNone);
|
304 | if (pointerEvents == RCTPointerEventsBoxNone) {
|
305 | self.accessibilityViewIsModal = NO;
|
306 | }
|
307 | }
|
308 |
|
309 | - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
|
310 | {
|
311 | BOOL canReceiveTouchEvents = ([self isUserInteractionEnabled] && ![self isHidden]);
|
312 | if(!canReceiveTouchEvents) {
|
313 | return nil;
|
314 | }
|
315 |
|
316 | // `hitSubview` is the topmost subview which was hit. The hit point can
|
317 | // be outside the bounds of `view` (e.g., if -clipsToBounds is NO).
|
318 | UIView *hitSubview = nil;
|
319 | BOOL isPointInside = [self pointInside:point withEvent:event];
|
320 | BOOL needsHitSubview = !(_pointerEvents == RCTPointerEventsNone || _pointerEvents == RCTPointerEventsBoxOnly);
|
321 | if (needsHitSubview && (![self clipsToBounds] || isPointInside)) {
|
322 | // Take z-index into account when calculating the touch target.
|
323 | NSArray<UIView *> *sortedSubviews = [self reactZIndexSortedSubviews];
|
324 |
|
325 | // The default behaviour of UIKit is that if a view does not contain a point,
|
326 | // then no subviews will be returned from hit testing, even if they contain
|
327 | // the hit point. By doing hit testing directly on the subviews, we bypass
|
328 | // the strict containment policy (i.e., UIKit guarantees that every ancestor
|
329 | // of the hit view will return YES from -pointInside:withEvent:). See:
|
330 | // - https://developer.apple.com/library/ios/qa/qa2013/qa1812.html
|
331 | for (UIView *subview in [sortedSubviews reverseObjectEnumerator]) {
|
332 | CGPoint convertedPoint = [subview convertPoint:point fromView:self];
|
333 | hitSubview = [subview hitTest:convertedPoint withEvent:event];
|
334 | if (hitSubview != nil) {
|
335 | break;
|
336 | }
|
337 | }
|
338 | }
|
339 |
|
340 | UIView *hitView = (isPointInside ? self : nil);
|
341 |
|
342 | switch (_pointerEvents) {
|
343 | case RCTPointerEventsNone:
|
344 | return nil;
|
345 | case RCTPointerEventsUnspecified:
|
346 | return hitSubview ?: hitView;
|
347 | case RCTPointerEventsBoxOnly:
|
348 | return hitView;
|
349 | case RCTPointerEventsBoxNone:
|
350 | return hitSubview;
|
351 | default:
|
352 | RCTLogError(@"Invalid pointer-events specified %lld on %@", (long long)_pointerEvents, self);
|
353 | return hitSubview ?: hitView;
|
354 | }
|
355 | }
|
356 |
|
357 | - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
|
358 | {
|
359 | if (UIEdgeInsetsEqualToEdgeInsets(self.hitTestEdgeInsets, UIEdgeInsetsZero)) {
|
360 | return [super pointInside:point withEvent:event];
|
361 | }
|
362 | CGRect hitFrame = UIEdgeInsetsInsetRect(self.bounds, self.hitTestEdgeInsets);
|
363 | return CGRectContainsPoint(hitFrame, point);
|
364 | }
|
365 |
|
366 | - (UIView *)reactAccessibilityElement
|
367 | {
|
368 | return self;
|
369 | }
|
370 |
|
371 | - (BOOL)isAccessibilityElement
|
372 | {
|
373 | if (self.reactAccessibilityElement == self) {
|
374 | return [super isAccessibilityElement];
|
375 | }
|
376 |
|
377 | return NO;
|
378 | }
|
379 |
|
380 | - (BOOL)performAccessibilityAction:(NSString *) name
|
381 | {
|
382 | if (_onAccessibilityAction && accessibilityActionsNameMap[name]) {
|
383 | _onAccessibilityAction(@{
|
384 | @"actionName" : name,
|
385 | @"actionTarget" : self.reactTag
|
386 | });
|
387 | return YES;
|
388 | }
|
389 | return NO;
|
390 | }
|
391 |
|
392 | - (BOOL)accessibilityActivate
|
393 | {
|
394 | if ([self performAccessibilityAction:@"activate"]) {
|
395 | return YES;
|
396 | } else if (_onAccessibilityTap) {
|
397 | _onAccessibilityTap(nil);
|
398 | return YES;
|
399 | } else {
|
400 | return NO;
|
401 | }
|
402 | }
|
403 |
|
404 | - (BOOL)accessibilityPerformMagicTap
|
405 | {
|
406 | if ([self performAccessibilityAction:@"magicTap"]) {
|
407 | return YES;
|
408 | } else if (_onMagicTap) {
|
409 | _onMagicTap(nil);
|
410 | return YES;
|
411 | } else {
|
412 | return NO;
|
413 | }
|
414 | }
|
415 |
|
416 | - (BOOL)accessibilityPerformEscape
|
417 | {
|
418 | if ([self performAccessibilityAction:@"escape"]) {
|
419 | return YES;
|
420 | } else if (_onAccessibilityEscape) {
|
421 | _onAccessibilityEscape(nil);
|
422 | return YES;
|
423 | } else {
|
424 | return NO;
|
425 | }
|
426 | }
|
427 |
|
428 | - (void)accessibilityIncrement
|
429 | {
|
430 | [self performAccessibilityAction:@"increment"];
|
431 | }
|
432 |
|
433 | - (void)accessibilityDecrement
|
434 | {
|
435 | [self performAccessibilityAction:@"decrement"];
|
436 | }
|
437 |
|
438 | - (NSString *)description
|
439 | {
|
440 | NSString *superDescription = super.description;
|
441 | NSRange semicolonRange = [superDescription rangeOfString:@";"];
|
442 | NSString *replacement = [NSString stringWithFormat:@"; reactTag: %@;", self.reactTag];
|
443 | return [superDescription stringByReplacingCharactersInRange:semicolonRange withString:replacement];
|
444 | }
|
445 |
|
446 | #pragma mark - Statics for dealing with layoutGuides
|
447 |
|
448 | + (void)autoAdjustInsetsForView:(UIView<RCTAutoInsetsProtocol> *)parentView
|
449 | withScrollView:(UIScrollView *)scrollView
|
450 | updateOffset:(BOOL)updateOffset
|
451 | {
|
452 | UIEdgeInsets baseInset = parentView.contentInset;
|
453 | CGFloat previousInsetTop = scrollView.contentInset.top;
|
454 | CGPoint contentOffset = scrollView.contentOffset;
|
455 |
|
456 | if (parentView.automaticallyAdjustContentInsets) {
|
457 | UIEdgeInsets autoInset = [self contentInsetsForView:parentView];
|
458 | baseInset.top += autoInset.top;
|
459 | baseInset.bottom += autoInset.bottom;
|
460 | baseInset.left += autoInset.left;
|
461 | baseInset.right += autoInset.right;
|
462 | }
|
463 | scrollView.contentInset = baseInset;
|
464 | scrollView.scrollIndicatorInsets = baseInset;
|
465 |
|
466 | if (updateOffset) {
|
467 | // If we're adjusting the top inset, then let's also adjust the contentOffset so that the view
|
468 | // elements above the top guide do not cover the content.
|
469 | // This is generally only needed when your views are initially laid out, for
|
470 | // manual changes to contentOffset, you can optionally disable this step
|
471 | CGFloat currentInsetTop = scrollView.contentInset.top;
|
472 | if (currentInsetTop != previousInsetTop) {
|
473 | contentOffset.y -= (currentInsetTop - previousInsetTop);
|
474 | scrollView.contentOffset = contentOffset;
|
475 | }
|
476 | }
|
477 | }
|
478 |
|
479 | + (UIEdgeInsets)contentInsetsForView:(UIView *)view
|
480 | {
|
481 | while (view) {
|
482 | UIViewController *controller = view.reactViewController;
|
483 | if (controller) {
|
484 | return (UIEdgeInsets){
|
485 | controller.topLayoutGuide.length, 0,
|
486 | controller.bottomLayoutGuide.length, 0
|
487 | };
|
488 | }
|
489 | view = view.superview;
|
490 | }
|
491 | return UIEdgeInsetsZero;
|
492 | }
|
493 |
|
494 | #pragma mark - View unmounting
|
495 |
|
496 | - (void)react_remountAllSubviews
|
497 | {
|
498 | if (_removeClippedSubviews) {
|
499 | for (UIView *view in self.reactSubviews) {
|
500 | if (view.superview != self) {
|
501 | [self addSubview:view];
|
502 | [view react_remountAllSubviews];
|
503 | }
|
504 | }
|
505 | } else {
|
506 | // If _removeClippedSubviews is false, we must already be showing all subviews
|
507 | [super react_remountAllSubviews];
|
508 | }
|
509 | }
|
510 |
|
511 | - (void)react_updateClippedSubviewsWithClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView
|
512 | {
|
513 | // TODO (#5906496): for scrollviews (the primary use-case) we could
|
514 | // optimize this by only doing a range check along the scroll axis,
|
515 | // instead of comparing the whole frame
|
516 |
|
517 | if (!_removeClippedSubviews) {
|
518 | // Use default behavior if unmounting is disabled
|
519 | return [super react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
|
520 | }
|
521 |
|
522 | if (self.reactSubviews.count == 0) {
|
523 | // Do nothing if we have no subviews
|
524 | return;
|
525 | }
|
526 |
|
527 | if (CGSizeEqualToSize(self.bounds.size, CGSizeZero)) {
|
528 | // Do nothing if layout hasn't happened yet
|
529 | return;
|
530 | }
|
531 |
|
532 | // Convert clipping rect to local coordinates
|
533 | clipRect = [clipView convertRect:clipRect toView:self];
|
534 | clipRect = CGRectIntersection(clipRect, self.bounds);
|
535 | clipView = self;
|
536 |
|
537 | // Mount / unmount views
|
538 | for (UIView *view in self.reactSubviews) {
|
539 | if (!CGSizeEqualToSize(CGRectIntersection(clipRect, view.frame).size, CGSizeZero)) {
|
540 | // View is at least partially visible, so remount it if unmounted
|
541 | [self addSubview:view];
|
542 |
|
543 | // Then test its subviews
|
544 | if (CGRectContainsRect(clipRect, view.frame)) {
|
545 | // View is fully visible, so remount all subviews
|
546 | [view react_remountAllSubviews];
|
547 | } else {
|
548 | // View is partially visible, so update clipped subviews
|
549 | [view react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
|
550 | }
|
551 |
|
552 | } else if (view.superview) {
|
553 |
|
554 | // View is completely outside the clipRect, so unmount it
|
555 | [view removeFromSuperview];
|
556 | }
|
557 | }
|
558 | }
|
559 |
|
560 | - (void)setRemoveClippedSubviews:(BOOL)removeClippedSubviews
|
561 | {
|
562 | if (!removeClippedSubviews && _removeClippedSubviews) {
|
563 | [self react_remountAllSubviews];
|
564 | }
|
565 | _removeClippedSubviews = removeClippedSubviews;
|
566 | }
|
567 |
|
568 | - (void)didUpdateReactSubviews
|
569 | {
|
570 | if (_removeClippedSubviews) {
|
571 | [self updateClippedSubviews];
|
572 | } else {
|
573 | [super didUpdateReactSubviews];
|
574 | }
|
575 | }
|
576 |
|
577 | - (void)updateClippedSubviews
|
578 | {
|
579 | // Find a suitable view to use for clipping
|
580 | UIView *clipView = [self react_findClipView];
|
581 | if (clipView) {
|
582 | [self react_updateClippedSubviewsWithClipRect:clipView.bounds relativeToView:clipView];
|
583 | }
|
584 | }
|
585 |
|
586 | - (void)layoutSubviews
|
587 | {
|
588 | // TODO (#5906496): this a nasty performance drain, but necessary
|
589 | // to prevent gaps appearing when the loading spinner disappears.
|
590 | // We might be able to fix this another way by triggering a call
|
591 | // to updateClippedSubviews manually after loading
|
592 |
|
593 | [super layoutSubviews];
|
594 |
|
595 | if (_removeClippedSubviews) {
|
596 | [self updateClippedSubviews];
|
597 | }
|
598 | }
|
599 |
|
600 | #pragma mark - Borders
|
601 |
|
602 | - (UIColor *)backgroundColor
|
603 | {
|
604 | return _backgroundColor;
|
605 | }
|
606 |
|
607 | - (void)setBackgroundColor:(UIColor *)backgroundColor
|
608 | {
|
609 | if ([_backgroundColor isEqual:backgroundColor]) {
|
610 | return;
|
611 | }
|
612 |
|
613 | _backgroundColor = backgroundColor;
|
614 | [self.layer setNeedsDisplay];
|
615 | }
|
616 |
|
617 | static CGFloat RCTDefaultIfNegativeTo(CGFloat defaultValue, CGFloat x) {
|
618 | return x >= 0 ? x : defaultValue;
|
619 | };
|
620 |
|
621 | - (UIEdgeInsets)bordersAsInsets
|
622 | {
|
623 | const CGFloat borderWidth = MAX(0, _borderWidth);
|
624 | const BOOL isRTL = _reactLayoutDirection == UIUserInterfaceLayoutDirectionRightToLeft;
|
625 |
|
626 | if ([[RCTI18nUtil sharedInstance] doLeftAndRightSwapInRTL]) {
|
627 | const CGFloat borderStartWidth = RCTDefaultIfNegativeTo(_borderLeftWidth, _borderStartWidth);
|
628 | const CGFloat borderEndWidth = RCTDefaultIfNegativeTo(_borderRightWidth, _borderEndWidth);
|
629 |
|
630 | const CGFloat directionAwareBorderLeftWidth = isRTL ? borderEndWidth : borderStartWidth;
|
631 | const CGFloat directionAwareBorderRightWidth = isRTL ? borderStartWidth : borderEndWidth;
|
632 |
|
633 | return (UIEdgeInsets) {
|
634 | RCTDefaultIfNegativeTo(borderWidth, _borderTopWidth),
|
635 | RCTDefaultIfNegativeTo(borderWidth, directionAwareBorderLeftWidth),
|
636 | RCTDefaultIfNegativeTo(borderWidth, _borderBottomWidth),
|
637 | RCTDefaultIfNegativeTo(borderWidth, directionAwareBorderRightWidth),
|
638 | };
|
639 | }
|
640 |
|
641 | const CGFloat directionAwareBorderLeftWidth = isRTL ? _borderEndWidth : _borderStartWidth;
|
642 | const CGFloat directionAwareBorderRightWidth = isRTL ? _borderStartWidth : _borderEndWidth;
|
643 |
|
644 | return (UIEdgeInsets) {
|
645 | RCTDefaultIfNegativeTo(borderWidth, _borderTopWidth),
|
646 | RCTDefaultIfNegativeTo(borderWidth, RCTDefaultIfNegativeTo(_borderLeftWidth, directionAwareBorderLeftWidth)),
|
647 | RCTDefaultIfNegativeTo(borderWidth, _borderBottomWidth),
|
648 | RCTDefaultIfNegativeTo(borderWidth, RCTDefaultIfNegativeTo(_borderRightWidth, directionAwareBorderRightWidth)),
|
649 | };
|
650 | }
|
651 |
|
652 | - (RCTCornerRadii)cornerRadii
|
653 | {
|
654 | const BOOL isRTL = _reactLayoutDirection == UIUserInterfaceLayoutDirectionRightToLeft;
|
655 | const CGFloat radius = MAX(0, _borderRadius);
|
656 |
|
657 | CGFloat topLeftRadius;
|
658 | CGFloat topRightRadius;
|
659 | CGFloat bottomLeftRadius;
|
660 | CGFloat bottomRightRadius;
|
661 |
|
662 | if ([[RCTI18nUtil sharedInstance] doLeftAndRightSwapInRTL]) {
|
663 | const CGFloat topStartRadius = RCTDefaultIfNegativeTo(_borderTopLeftRadius, _borderTopStartRadius);
|
664 | const CGFloat topEndRadius = RCTDefaultIfNegativeTo(_borderTopRightRadius, _borderTopEndRadius);
|
665 | const CGFloat bottomStartRadius = RCTDefaultIfNegativeTo(_borderBottomLeftRadius, _borderBottomStartRadius);
|
666 | const CGFloat bottomEndRadius = RCTDefaultIfNegativeTo(_borderBottomRightRadius, _borderBottomEndRadius);
|
667 |
|
668 | const CGFloat directionAwareTopLeftRadius = isRTL ? topEndRadius : topStartRadius;
|
669 | const CGFloat directionAwareTopRightRadius = isRTL ? topStartRadius : topEndRadius;
|
670 | const CGFloat directionAwareBottomLeftRadius = isRTL ? bottomEndRadius : bottomStartRadius;
|
671 | const CGFloat directionAwareBottomRightRadius = isRTL ? bottomStartRadius : bottomEndRadius;
|
672 |
|
673 | topLeftRadius = RCTDefaultIfNegativeTo(radius, directionAwareTopLeftRadius);
|
674 | topRightRadius = RCTDefaultIfNegativeTo(radius, directionAwareTopRightRadius);
|
675 | bottomLeftRadius = RCTDefaultIfNegativeTo(radius, directionAwareBottomLeftRadius);
|
676 | bottomRightRadius = RCTDefaultIfNegativeTo(radius, directionAwareBottomRightRadius);
|
677 | } else {
|
678 | const CGFloat directionAwareTopLeftRadius = isRTL ? _borderTopEndRadius : _borderTopStartRadius;
|
679 | const CGFloat directionAwareTopRightRadius = isRTL ? _borderTopStartRadius : _borderTopEndRadius;
|
680 | const CGFloat directionAwareBottomLeftRadius = isRTL ? _borderBottomEndRadius : _borderBottomStartRadius;
|
681 | const CGFloat directionAwareBottomRightRadius = isRTL ? _borderBottomStartRadius : _borderBottomEndRadius;
|
682 |
|
683 | topLeftRadius = RCTDefaultIfNegativeTo(radius, RCTDefaultIfNegativeTo(_borderTopLeftRadius, directionAwareTopLeftRadius));
|
684 | topRightRadius = RCTDefaultIfNegativeTo(radius, RCTDefaultIfNegativeTo(_borderTopRightRadius, directionAwareTopRightRadius));
|
685 | bottomLeftRadius = RCTDefaultIfNegativeTo(radius, RCTDefaultIfNegativeTo(_borderBottomLeftRadius, directionAwareBottomLeftRadius));
|
686 | bottomRightRadius = RCTDefaultIfNegativeTo(radius, RCTDefaultIfNegativeTo(_borderBottomRightRadius, directionAwareBottomRightRadius));
|
687 | }
|
688 |
|
689 | // Get scale factors required to prevent radii from overlapping
|
690 | const CGSize size = self.bounds.size;
|
691 | const CGFloat topScaleFactor = RCTZeroIfNaN(MIN(1, size.width / (topLeftRadius + topRightRadius)));
|
692 | const CGFloat bottomScaleFactor = RCTZeroIfNaN(MIN(1, size.width / (bottomLeftRadius + bottomRightRadius)));
|
693 | const CGFloat rightScaleFactor = RCTZeroIfNaN(MIN(1, size.height / (topRightRadius + bottomRightRadius)));
|
694 | const CGFloat leftScaleFactor = RCTZeroIfNaN(MIN(1, size.height / (topLeftRadius + bottomLeftRadius)));
|
695 |
|
696 | // Return scaled radii
|
697 | return (RCTCornerRadii){
|
698 | topLeftRadius * MIN(topScaleFactor, leftScaleFactor),
|
699 | topRightRadius * MIN(topScaleFactor, rightScaleFactor),
|
700 | bottomLeftRadius * MIN(bottomScaleFactor, leftScaleFactor),
|
701 | bottomRightRadius * MIN(bottomScaleFactor, rightScaleFactor),
|
702 | };
|
703 | }
|
704 |
|
705 | - (RCTBorderColors)borderColors
|
706 | {
|
707 | const BOOL isRTL = _reactLayoutDirection == UIUserInterfaceLayoutDirectionRightToLeft;
|
708 |
|
709 | if ([[RCTI18nUtil sharedInstance] doLeftAndRightSwapInRTL]) {
|
710 | const CGColorRef borderStartColor = _borderStartColor ?: _borderLeftColor;
|
711 | const CGColorRef borderEndColor = _borderEndColor ?: _borderRightColor;
|
712 |
|
713 | const CGColorRef directionAwareBorderLeftColor = isRTL ? borderEndColor : borderStartColor;
|
714 | const CGColorRef directionAwareBorderRightColor = isRTL ? borderStartColor : borderEndColor;
|
715 |
|
716 | return (RCTBorderColors){
|
717 | _borderTopColor ?: _borderColor,
|
718 | directionAwareBorderLeftColor ?: _borderColor,
|
719 | _borderBottomColor ?: _borderColor,
|
720 | directionAwareBorderRightColor ?: _borderColor,
|
721 | };
|
722 | }
|
723 |
|
724 | const CGColorRef directionAwareBorderLeftColor = isRTL ? _borderEndColor : _borderStartColor;
|
725 | const CGColorRef directionAwareBorderRightColor = isRTL ? _borderStartColor : _borderEndColor;
|
726 |
|
727 | return (RCTBorderColors){
|
728 | _borderTopColor ?: _borderColor,
|
729 | directionAwareBorderLeftColor ?: _borderLeftColor ?: _borderColor,
|
730 | _borderBottomColor ?: _borderColor,
|
731 | directionAwareBorderRightColor ?: _borderRightColor ?: _borderColor,
|
732 | };
|
733 | }
|
734 |
|
735 | - (void)reactSetFrame:(CGRect)frame
|
736 | {
|
737 | // If frame is zero, or below the threshold where the border radii can
|
738 | // be rendered as a stretchable image, we'll need to re-render.
|
739 | // TODO: detect up-front if re-rendering is necessary
|
740 | CGSize oldSize = self.bounds.size;
|
741 | [super reactSetFrame:frame];
|
742 | if (!CGSizeEqualToSize(self.bounds.size, oldSize)) {
|
743 | [self.layer setNeedsDisplay];
|
744 | }
|
745 | }
|
746 |
|
747 | - (void)displayLayer:(CALayer *)layer
|
748 | {
|
749 | if (CGSizeEqualToSize(layer.bounds.size, CGSizeZero)) {
|
750 | return;
|
751 | }
|
752 |
|
753 | RCTUpdateShadowPathForView(self);
|
754 |
|
755 | const RCTCornerRadii cornerRadii = [self cornerRadii];
|
756 | const UIEdgeInsets borderInsets = [self bordersAsInsets];
|
757 | const RCTBorderColors borderColors = [self borderColors];
|
758 |
|
759 | BOOL useIOSBorderRendering =
|
760 | RCTCornerRadiiAreEqual(cornerRadii) &&
|
761 | RCTBorderInsetsAreEqual(borderInsets) &&
|
762 | RCTBorderColorsAreEqual(borderColors) &&
|
763 | _borderStyle == RCTBorderStyleSolid &&
|
764 |
|
765 | // iOS draws borders in front of the content whereas CSS draws them behind
|
766 | // the content. For this reason, only use iOS border drawing when clipping
|
767 | // or when the border is hidden.
|
768 |
|
769 | (borderInsets.top == 0 || (borderColors.top && CGColorGetAlpha(borderColors.top) == 0) || self.clipsToBounds);
|
770 |
|
771 | // iOS clips to the outside of the border, but CSS clips to the inside. To
|
772 | // solve this, we'll need to add a container view inside the main view to
|
773 | // correctly clip the subviews.
|
774 |
|
775 | if (useIOSBorderRendering) {
|
776 | layer.cornerRadius = cornerRadii.topLeft;
|
777 | layer.borderColor = borderColors.left;
|
778 | layer.borderWidth = borderInsets.left;
|
779 | layer.backgroundColor = _backgroundColor.CGColor;
|
780 | layer.contents = nil;
|
781 | layer.needsDisplayOnBoundsChange = NO;
|
782 | layer.mask = nil;
|
783 | return;
|
784 | }
|
785 |
|
786 | UIImage *image = RCTGetBorderImage(_borderStyle,
|
787 | layer.bounds.size,
|
788 | cornerRadii,
|
789 | borderInsets,
|
790 | borderColors,
|
791 | _backgroundColor.CGColor,
|
792 | self.clipsToBounds);
|
793 |
|
794 | layer.backgroundColor = NULL;
|
795 |
|
796 | if (image == nil) {
|
797 | layer.contents = nil;
|
798 | layer.needsDisplayOnBoundsChange = NO;
|
799 | return;
|
800 | }
|
801 |
|
802 | CGRect contentsCenter = ({
|
803 | CGSize size = image.size;
|
804 | UIEdgeInsets insets = image.capInsets;
|
805 | CGRectMake(
|
806 | insets.left / size.width,
|
807 | insets.top / size.height,
|
808 | (CGFloat)1.0 / size.width,
|
809 | (CGFloat)1.0 / size.height
|
810 | );
|
811 | });
|
812 |
|
813 | layer.contents = (id)image.CGImage;
|
814 | layer.contentsScale = image.scale;
|
815 | layer.needsDisplayOnBoundsChange = YES;
|
816 | layer.magnificationFilter = kCAFilterNearest;
|
817 |
|
818 | const BOOL isResizable = !UIEdgeInsetsEqualToEdgeInsets(image.capInsets, UIEdgeInsetsZero);
|
819 | if (isResizable) {
|
820 | layer.contentsCenter = contentsCenter;
|
821 | } else {
|
822 | layer.contentsCenter = CGRectMake(0.0, 0.0, 1.0, 1.0);
|
823 | }
|
824 |
|
825 | [self updateClippingForLayer:layer];
|
826 | }
|
827 |
|
828 | static BOOL RCTLayerHasShadow(CALayer *layer)
|
829 | {
|
830 | return layer.shadowOpacity * CGColorGetAlpha(layer.shadowColor) > 0;
|
831 | }
|
832 |
|
833 | static void RCTUpdateShadowPathForView(RCTView *view)
|
834 | {
|
835 | if (RCTLayerHasShadow(view.layer)) {
|
836 | if (CGColorGetAlpha(view.backgroundColor.CGColor) > 0.999) {
|
837 |
|
838 | // If view has a solid background color, calculate shadow path from border
|
839 | const RCTCornerRadii cornerRadii = [view cornerRadii];
|
840 | const RCTCornerInsets cornerInsets = RCTGetCornerInsets(cornerRadii, UIEdgeInsetsZero);
|
841 | CGPathRef shadowPath = RCTPathCreateWithRoundedRect(view.bounds, cornerInsets, NULL);
|
842 | view.layer.shadowPath = shadowPath;
|
843 | CGPathRelease(shadowPath);
|
844 |
|
845 | } else {
|
846 |
|
847 | // Can't accurately calculate box shadow, so fall back to pixel-based shadow
|
848 | view.layer.shadowPath = nil;
|
849 |
|
850 | RCTLogAdvice(@"View #%@ of type %@ has a shadow set but cannot calculate "
|
851 | "shadow efficiently. Consider setting a background color to "
|
852 | "fix this, or apply the shadow to a more specific component.",
|
853 | view.reactTag, [view class]);
|
854 | }
|
855 | }
|
856 | }
|
857 |
|
858 | - (void)updateClippingForLayer:(CALayer *)layer
|
859 | {
|
860 | CALayer *mask = nil;
|
861 | CGFloat cornerRadius = 0;
|
862 |
|
863 | if (self.clipsToBounds) {
|
864 |
|
865 | const RCTCornerRadii cornerRadii = [self cornerRadii];
|
866 | if (RCTCornerRadiiAreEqual(cornerRadii)) {
|
867 |
|
868 | cornerRadius = cornerRadii.topLeft;
|
869 |
|
870 | } else {
|
871 |
|
872 | CAShapeLayer *shapeLayer = [CAShapeLayer layer];
|
873 | CGPathRef path = RCTPathCreateWithRoundedRect(self.bounds, RCTGetCornerInsets(cornerRadii, UIEdgeInsetsZero), NULL);
|
874 | shapeLayer.path = path;
|
875 | CGPathRelease(path);
|
876 | mask = shapeLayer;
|
877 | }
|
878 | }
|
879 |
|
880 | layer.cornerRadius = cornerRadius;
|
881 | layer.mask = mask;
|
882 | }
|
883 |
|
884 | #pragma mark Border Color
|
885 |
|
886 | #define setBorderColor(side) \
|
887 | - (void)setBorder##side##Color:(CGColorRef)color \
|
888 | { \
|
889 | if (CGColorEqualToColor(_border##side##Color, color)) { \
|
890 | return; \
|
891 | } \
|
892 | CGColorRelease(_border##side##Color); \
|
893 | _border##side##Color = CGColorRetain(color); \
|
894 | [self.layer setNeedsDisplay]; \
|
895 | }
|
896 |
|
897 | setBorderColor()
|
898 | setBorderColor(Top)
|
899 | setBorderColor(Right)
|
900 | setBorderColor(Bottom)
|
901 | setBorderColor(Left)
|
902 | setBorderColor(Start)
|
903 | setBorderColor(End)
|
904 |
|
905 | #pragma mark - Border Width
|
906 |
|
907 | #define setBorderWidth(side) \
|
908 | - (void)setBorder##side##Width:(CGFloat)width \
|
909 | { \
|
910 | if (_border##side##Width == width) { \
|
911 | return; \
|
912 | } \
|
913 | _border##side##Width = width; \
|
914 | [self.layer setNeedsDisplay]; \
|
915 | }
|
916 |
|
917 | setBorderWidth()
|
918 | setBorderWidth(Top)
|
919 | setBorderWidth(Right)
|
920 | setBorderWidth(Bottom)
|
921 | setBorderWidth(Left)
|
922 | setBorderWidth(Start)
|
923 | setBorderWidth(End)
|
924 |
|
925 | #pragma mark - Border Radius
|
926 |
|
927 | #define setBorderRadius(side) \
|
928 | - (void)setBorder##side##Radius:(CGFloat)radius \
|
929 | { \
|
930 | if (_border##side##Radius == radius) { \
|
931 | return; \
|
932 | } \
|
933 | _border##side##Radius = radius; \
|
934 | [self.layer setNeedsDisplay]; \
|
935 | }
|
936 |
|
937 | setBorderRadius()
|
938 | setBorderRadius(TopLeft)
|
939 | setBorderRadius(TopRight)
|
940 | setBorderRadius(TopStart)
|
941 | setBorderRadius(TopEnd)
|
942 | setBorderRadius(BottomLeft)
|
943 | setBorderRadius(BottomRight)
|
944 | setBorderRadius(BottomStart)
|
945 | setBorderRadius(BottomEnd)
|
946 |
|
947 | #pragma mark - Border Style
|
948 |
|
949 | #define setBorderStyle(side) \
|
950 | - (void)setBorder##side##Style:(RCTBorderStyle)style \
|
951 | { \
|
952 | if (_border##side##Style == style) { \
|
953 | return; \
|
954 | } \
|
955 | _border##side##Style = style; \
|
956 | [self.layer setNeedsDisplay]; \
|
957 | }
|
958 |
|
959 | setBorderStyle()
|
960 |
|
961 | - (void)dealloc
|
962 | {
|
963 | CGColorRelease(_borderColor);
|
964 | CGColorRelease(_borderTopColor);
|
965 | CGColorRelease(_borderRightColor);
|
966 | CGColorRelease(_borderBottomColor);
|
967 | CGColorRelease(_borderLeftColor);
|
968 | CGColorRelease(_borderStartColor);
|
969 | CGColorRelease(_borderEndColor);
|
970 | }
|
971 |
|
972 | @end
|