|
| 1 | +/* |
| 2 | + * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | + * |
| 4 | + * This source code is licensed under the MIT license found in the |
| 5 | + * LICENSE file in the root directory of this source tree. |
| 6 | + */ |
| 7 | + |
| 8 | +#import "RCTVirtualViewComponentView.h" |
| 9 | + |
| 10 | +#import <React/RCTAssert.h> |
| 11 | +#import <React/RCTConversions.h> |
| 12 | +#import <React/RCTScrollViewComponentView.h> |
| 13 | +#import <React/RCTScrollableProtocol.h> |
| 14 | +#import <React/UIView+React.h> |
| 15 | +#import <jsi/jsi.h> |
| 16 | +#import <react/featureflags/ReactNativeFeatureFlags.h> |
| 17 | +#import <react/renderer/components/virtualview/VirtualViewComponentDescriptor.h> |
| 18 | + |
| 19 | +#import "Plugins.h" |
| 20 | + |
| 21 | +using namespace facebook; |
| 22 | +using namespace facebook::react; |
| 23 | + |
| 24 | +typedef NS_ENUM(NSInteger, RCTVirtualViewMode) { |
| 25 | + RCTVirtualViewModeVisible = 0, |
| 26 | + RCTVirtualViewModePrerender = 1, |
| 27 | + RCTVirtualViewModeHidden = 2, |
| 28 | +}; |
| 29 | + |
| 30 | +/** |
| 31 | + * Checks whether one CGRect overlaps with another CGRect. |
| 32 | + * |
| 33 | + * This is different from CGRectIntersectsRect because a CGRect representing |
| 34 | + * a line or a point is considered to overlap with another CGRect if the line |
| 35 | + * or point is within the rect bounds. However, two CGRects are not considered |
| 36 | + * to overlap if they only share a boundary. |
| 37 | + */ |
| 38 | +static BOOL CGRectOverlaps(CGRect rect1, CGRect rect2) |
| 39 | +{ |
| 40 | + CGFloat minY1 = CGRectGetMinY(rect1); |
| 41 | + CGFloat maxY1 = CGRectGetMaxY(rect1); |
| 42 | + CGFloat minY2 = CGRectGetMinY(rect2); |
| 43 | + CGFloat maxY2 = CGRectGetMaxY(rect2); |
| 44 | + if (minY1 >= maxY2 || minY2 >= maxY1) { |
| 45 | + // No overlap on the y-axis. |
| 46 | + return NO; |
| 47 | + } |
| 48 | + CGFloat minX1 = CGRectGetMinX(rect1); |
| 49 | + CGFloat maxX1 = CGRectGetMaxX(rect1); |
| 50 | + CGFloat minX2 = CGRectGetMinX(rect2); |
| 51 | + CGFloat maxX2 = CGRectGetMaxX(rect2); |
| 52 | + if (minX1 >= maxX2 || minX2 >= maxX1) { |
| 53 | + // No overlap on the x-axis. |
| 54 | + return NO; |
| 55 | + } |
| 56 | + return YES; |
| 57 | +} |
| 58 | + |
| 59 | +@interface RCTVirtualViewComponentView () <UIScrollViewDelegate> |
| 60 | +@end |
| 61 | + |
| 62 | +@implementation RCTVirtualViewComponentView { |
| 63 | + RCTScrollViewComponentView *_lastParentScrollViewComponentView; |
| 64 | + std::optional<enum RCTVirtualViewMode> _mode; |
| 65 | + std::optional<CGRect> _targetRect; |
| 66 | +} |
| 67 | + |
| 68 | +- (instancetype)initWithFrame:(CGRect)frame |
| 69 | +{ |
| 70 | + if (self = [super initWithFrame:frame]) { |
| 71 | + _props = VirtualViewShadowNode::defaultSharedProps(); |
| 72 | + } |
| 73 | + |
| 74 | + return self; |
| 75 | +} |
| 76 | + |
| 77 | +- (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps |
| 78 | +{ |
| 79 | + if (!_mode.has_value()) { |
| 80 | + const auto &newViewProps = static_cast<const VirtualViewProps &>(*props); |
| 81 | + _mode = newViewProps.initialHidden ? RCTVirtualViewModeHidden : RCTVirtualViewModeVisible; |
| 82 | + } |
| 83 | + |
| 84 | + [super updateProps:props oldProps:oldProps]; |
| 85 | +} |
| 86 | + |
| 87 | +- (RCTScrollViewComponentView *)getParentScrollViewComponentView |
| 88 | +{ |
| 89 | + UIView *view = self.superview; |
| 90 | + while (view != nil) { |
| 91 | + if ([view isKindOfClass:[RCTScrollViewComponentView class]]) { |
| 92 | + return (RCTScrollViewComponentView *)view; |
| 93 | + } |
| 94 | + view = view.superview; |
| 95 | + } |
| 96 | + return nil; |
| 97 | +} |
| 98 | + |
| 99 | +- (void)prepareForRecycle |
| 100 | +{ |
| 101 | + [super prepareForRecycle]; |
| 102 | + |
| 103 | + // No need to remove the scroll listener here since the view is always removed from window before being recycled and |
| 104 | + // we do that in didMoveToWindow, which gets called when the view is removed from window. |
| 105 | + RCTAssert( |
| 106 | + _lastParentScrollViewComponentView == nil, |
| 107 | + @"_lastParentScrollViewComponentView should already have been cleared in didMoveToWindow."); |
| 108 | + |
| 109 | + _mode.reset(); |
| 110 | + _targetRect.reset(); |
| 111 | +} |
| 112 | + |
| 113 | +// Handles case when sibling changes size. |
| 114 | +// TODO(T202601695): This doesn't yet handle the case of elements in the ScrollView outside a VirtualColumn changing |
| 115 | +// size. |
| 116 | +- (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics |
| 117 | + oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics |
| 118 | +{ |
| 119 | + [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:_layoutMetrics]; |
| 120 | + |
| 121 | + [self dispatchOnModeChangeIfNeeded:YES]; |
| 122 | +} |
| 123 | + |
| 124 | +- (void)didMoveToWindow |
| 125 | +{ |
| 126 | + [super didMoveToWindow]; |
| 127 | + |
| 128 | + if (_lastParentScrollViewComponentView) { |
| 129 | + [_lastParentScrollViewComponentView removeScrollListener:self]; |
| 130 | + _lastParentScrollViewComponentView = nil; |
| 131 | + } |
| 132 | + |
| 133 | + if (RCTScrollViewComponentView *parentScrollViewComponentView = [self getParentScrollViewComponentView]) { |
| 134 | + if (self.window) { |
| 135 | + // TODO(T202601695): We also want the ScrollView to emit layout changes from didLayoutSubviews so that any event |
| 136 | + // that may affect visibily of this view notifies the listeners. |
| 137 | + [parentScrollViewComponentView addScrollListener:self]; |
| 138 | + _lastParentScrollViewComponentView = parentScrollViewComponentView; |
| 139 | + |
| 140 | + // We want to dispatch the event immediately when the view is added to the window before any scrolling occurs. |
| 141 | + [self dispatchOnModeChangeIfNeeded:NO]; |
| 142 | + } |
| 143 | + } |
| 144 | +} |
| 145 | + |
| 146 | +- (void)scrollViewDidScroll:(UIScrollView *)scrollView |
| 147 | +{ |
| 148 | + [self dispatchOnModeChangeIfNeeded:NO]; |
| 149 | +} |
| 150 | + |
| 151 | +- (void)dispatchOnModeChangeIfNeeded:(BOOL)checkForTargetRectChange |
| 152 | +{ |
| 153 | + if (!_lastParentScrollViewComponentView) { |
| 154 | + return; |
| 155 | + } |
| 156 | + |
| 157 | + UIScrollView *scrollView = _lastParentScrollViewComponentView.scrollView; |
| 158 | + CGRect targetRect = [self convertRect:self.bounds toView:scrollView]; |
| 159 | + |
| 160 | + // While scrolling, the `targetRect` does not change, so we don't check for changed `targetRect` in that case. |
| 161 | + if (checkForTargetRectChange) { |
| 162 | + if (_targetRect.has_value() && CGRectEqualToRect(targetRect, _targetRect.value())) { |
| 163 | + return; |
| 164 | + } |
| 165 | + _targetRect = targetRect; |
| 166 | + } |
| 167 | + |
| 168 | + enum RCTVirtualViewMode newMode; |
| 169 | + CGRect thresholdRect = CGRectMake( |
| 170 | + scrollView.contentOffset.x, |
| 171 | + scrollView.contentOffset.y, |
| 172 | + scrollView.frame.size.width, |
| 173 | + scrollView.frame.size.height); |
| 174 | + if (CGRectOverlaps(targetRect, thresholdRect)) { |
| 175 | + newMode = RCTVirtualViewModeVisible; |
| 176 | + } else { |
| 177 | + auto prerender = false; |
| 178 | + const CGFloat prerenderRatio = ReactNativeFeatureFlags::virtualViewPrerenderRatio(); |
| 179 | + if (prerenderRatio > 0) { |
| 180 | + thresholdRect = CGRectInset( |
| 181 | + thresholdRect, -thresholdRect.size.width * prerenderRatio, -thresholdRect.size.height * prerenderRatio); |
| 182 | + prerender = CGRectOverlaps(targetRect, thresholdRect); |
| 183 | + } |
| 184 | + if (prerender) { |
| 185 | + newMode = RCTVirtualViewModePrerender; |
| 186 | + } else { |
| 187 | + newMode = RCTVirtualViewModeHidden; |
| 188 | + thresholdRect = CGRectZero; |
| 189 | + } |
| 190 | + } |
| 191 | + |
| 192 | + if (_mode.has_value() && newMode == _mode.value()) { |
| 193 | + return; |
| 194 | + } |
| 195 | + |
| 196 | + // NOTE: Make sure to keep these props in sync with dispatchSyncModeChange below where we have to explicitly copy all |
| 197 | + // props. |
| 198 | + VirtualViewEventEmitter::OnModeChange event = { |
| 199 | + .mode = (int)newMode, |
| 200 | + .targetRect = |
| 201 | + {.x = targetRect.origin.x, |
| 202 | + .y = targetRect.origin.y, |
| 203 | + .width = targetRect.size.width, |
| 204 | + .height = targetRect.size.height}, |
| 205 | + .thresholdRect = |
| 206 | + {.x = thresholdRect.origin.x, |
| 207 | + .y = thresholdRect.origin.y, |
| 208 | + .width = thresholdRect.size.width, |
| 209 | + .height = thresholdRect.size.height}, |
| 210 | + }; |
| 211 | + |
| 212 | + const std::optional<enum RCTVirtualViewMode> oldMode = _mode; |
| 213 | + _mode = newMode; |
| 214 | + |
| 215 | + switch (newMode) { |
| 216 | + case RCTVirtualViewModeVisible: |
| 217 | + [self dispatchSyncModeChange:event]; |
| 218 | + break; |
| 219 | + case RCTVirtualViewModePrerender: |
| 220 | + if (!oldMode.has_value() || oldMode != RCTVirtualViewModeVisible) { |
| 221 | + [self dispatchAsyncModeChange:event]; |
| 222 | + } |
| 223 | + break; |
| 224 | + case RCTVirtualViewModeHidden: |
| 225 | + [self dispatchAsyncModeChange:event]; |
| 226 | + break; |
| 227 | + } |
| 228 | +} |
| 229 | + |
| 230 | +- (void)dispatchAsyncModeChange:(VirtualViewEventEmitter::OnModeChange &)event |
| 231 | +{ |
| 232 | + if (!_eventEmitter) { |
| 233 | + return; |
| 234 | + } |
| 235 | + |
| 236 | + std::shared_ptr<const VirtualViewEventEmitter> emitter = |
| 237 | + std::static_pointer_cast<const VirtualViewEventEmitter>(_eventEmitter); |
| 238 | + emitter->onModeChange(event); |
| 239 | +} |
| 240 | + |
| 241 | +- (void)dispatchSyncModeChange:(VirtualViewEventEmitter::OnModeChange &)event |
| 242 | +{ |
| 243 | + if (!_eventEmitter) { |
| 244 | + return; |
| 245 | + } |
| 246 | + |
| 247 | + std::shared_ptr<const VirtualViewEventEmitter> emitter = |
| 248 | + std::static_pointer_cast<const VirtualViewEventEmitter>(_eventEmitter); |
| 249 | + |
| 250 | + // TODO: Move this into a custom event emitter. We had to duplicate the codegen code here from onModeChange in order |
| 251 | + // to dispatch synchronously and discrete. |
| 252 | + emitter->experimental_flushSync([&emitter, &event]() { |
| 253 | + emitter->dispatchEvent( |
| 254 | + "modeChange", |
| 255 | + [event](jsi::Runtime &runtime) { |
| 256 | + auto payload = jsi::Object(runtime); |
| 257 | + payload.setProperty(runtime, "mode", event.mode); |
| 258 | + { |
| 259 | + auto targetRect = jsi::Object(runtime); |
| 260 | + targetRect.setProperty(runtime, "x", event.targetRect.x); |
| 261 | + targetRect.setProperty(runtime, "y", event.targetRect.y); |
| 262 | + targetRect.setProperty(runtime, "width", event.targetRect.width); |
| 263 | + targetRect.setProperty(runtime, "height", event.targetRect.height); |
| 264 | + payload.setProperty(runtime, "targetRect", targetRect); |
| 265 | + } |
| 266 | + { |
| 267 | + auto thresholdRect = jsi::Object(runtime); |
| 268 | + thresholdRect.setProperty(runtime, "x", event.thresholdRect.x); |
| 269 | + thresholdRect.setProperty(runtime, "y", event.thresholdRect.y); |
| 270 | + thresholdRect.setProperty(runtime, "width", event.thresholdRect.width); |
| 271 | + thresholdRect.setProperty(runtime, "height", event.thresholdRect.height); |
| 272 | + payload.setProperty(runtime, "thresholdRect", thresholdRect); |
| 273 | + } |
| 274 | + return payload; |
| 275 | + }, |
| 276 | + RawEvent::Category::Discrete); |
| 277 | + }); |
| 278 | +} |
| 279 | + |
| 280 | ++ (ComponentDescriptorProvider)componentDescriptorProvider |
| 281 | +{ |
| 282 | + return concreteComponentDescriptorProvider<VirtualViewComponentDescriptor>(); |
| 283 | +} |
| 284 | + |
| 285 | +@end |
| 286 | + |
| 287 | +Class<RCTComponentViewProtocol> VirtualViewCls(void) |
| 288 | +{ |
| 289 | + return RCTVirtualViewComponentView.class; |
| 290 | +} |
0 commit comments