|
10 | 10 |
|
11 | 11 | #import <React/RCTParagraphComponentView.h> |
12 | 12 |
|
| 13 | +#import <React/RCTTouchableComponentViewProtocol.h> |
13 | 14 | #import <react/renderer/components/text/ParagraphComponentDescriptor.h> |
14 | 15 | #import <react/renderer/components/text/ParagraphProps.h> |
15 | 16 | #import <react/renderer/components/text/ParagraphShadowNode.h> |
16 | 17 | #import <react/renderer/components/text/ParagraphState.h> |
| 18 | +#import <react/renderer/components/text/TextShadowNode.h> |
| 19 | +#import <react/renderer/components/view/TouchEventEmitter.h> |
| 20 | +#import <react/renderer/core/EventDispatcher.h> |
17 | 21 | #import <react/renderer/graphics/Color.h> |
18 | 22 | #import <react/renderer/textlayoutmanager/TextLayoutManager.h> |
19 | 23 | #import <react/utils/ContextContainer.h> |
@@ -46,16 +50,73 @@ - (void)setUp |
46 | 50 | * a strikethrough and a shadow. |
47 | 51 | */ |
48 | 52 | - (AttributedString)decoratedAttributedString |
| 53 | +{ |
| 54 | + return [self decoratedAttributedStringHighlighted:NO]; |
| 55 | +} |
| 56 | + |
| 57 | +- (AttributedString)decoratedAttributedStringHighlighted:(BOOL)highlighted |
49 | 58 | { |
50 | 59 | auto textAttributes = TextAttributes{}; |
51 | 60 | textAttributes.foregroundColor = colorFromRGBA(255, 0, 0, 255); |
52 | 61 | textAttributes.fontSize = 20; |
53 | 62 | textAttributes.textDecorationLineType = TextDecorationLineType::UnderlineStrikethrough; |
54 | 63 | textAttributes.textDecorationStyle = TextDecorationStyle::Wavy; |
| 64 | + if (highlighted) { |
| 65 | + textAttributes.isHighlighted = true; |
| 66 | + } |
| 67 | + |
| 68 | + auto fragment = AttributedString::Fragment{}; |
| 69 | + fragment.string = "Selectable wavy decoration"; |
| 70 | + fragment.textAttributes = textAttributes; |
| 71 | + |
| 72 | + auto attributedString = AttributedString{}; |
| 73 | + attributedString.appendFragment(std::move(fragment)); |
| 74 | + return attributedString; |
| 75 | +} |
| 76 | + |
| 77 | +/* |
| 78 | + * A paragraph whose single fragment carries a touch emitter, which is how a |
| 79 | + * nested pressable <Text> reaches `touchEventEmitterAtPoint:`. |
| 80 | + */ |
| 81 | +- (AttributedString)attributedStringWithEventEmitter:(std::shared_ptr<TouchEventEmitter>)eventEmitter |
| 82 | +{ |
| 83 | + auto textAttributes = TextAttributes{}; |
| 84 | + textAttributes.fontSize = 20; |
55 | 85 |
|
56 | 86 | auto fragment = AttributedString::Fragment{}; |
57 | 87 | fragment.string = "Selectable wavy decoration"; |
58 | 88 | fragment.textAttributes = textAttributes; |
| 89 | + fragment.parentShadowView.eventEmitter = eventEmitter; |
| 90 | + // The emitter only reaches the attributed string when the fragment names a |
| 91 | + // component, which is what marks it as a nested <Text> rather than raw text. |
| 92 | + fragment.parentShadowView.componentHandle = TextShadowNode::Handle(); |
| 93 | + |
| 94 | + auto attributedString = AttributedString{}; |
| 95 | + attributedString.appendFragment(std::move(fragment)); |
| 96 | + return attributedString; |
| 97 | +} |
| 98 | + |
| 99 | +- (RCTParagraphComponentView *)paragraphViewWithEventEmitter:(std::shared_ptr<TouchEventEmitter>)eventEmitter |
| 100 | +{ |
| 101 | + RCTParagraphComponentView *view = [RCTParagraphComponentView new]; |
| 102 | + view.frame = CGRectMake(0, 0, 320, 100); |
| 103 | + |
| 104 | + [view updateProps:[self propsWithSelectable:YES] oldProps:nullptr]; |
| 105 | + [view updateState:[self stateWithAttributedString:[self attributedStringWithEventEmitter:eventEmitter]] oldState:nil]; |
| 106 | + |
| 107 | + auto layoutMetrics = LayoutMetrics{}; |
| 108 | + layoutMetrics.frame = facebook::react::Rect{facebook::react::Point{0, 0}, facebook::react::Size{320, 100}}; |
| 109 | + [view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:{}]; |
| 110 | + |
| 111 | + [view layoutIfNeeded]; |
| 112 | + return view; |
| 113 | +} |
| 114 | + |
| 115 | +- (AttributedString)attributedStringWithText:(std::string)text |
| 116 | +{ |
| 117 | + auto fragment = AttributedString::Fragment{}; |
| 118 | + fragment.string = std::move(text); |
| 119 | + fragment.textAttributes = TextAttributes{}; |
59 | 120 |
|
60 | 121 | auto attributedString = AttributedString{}; |
61 | 122 | attributedString.appendFragment(std::move(fragment)); |
@@ -291,6 +352,85 @@ - (void)testRecyclingRemovesTheSelectionTextView |
291 | 352 | XCTAssertNil([self selectionTextViewIn:view], @"A recycled paragraph must not keep a selection text view."); |
292 | 353 | } |
293 | 354 |
|
| 355 | +#pragma mark - A press must not rebuild the selection |
| 356 | + |
| 357 | +/* |
| 358 | + * Pressing a nested pressable <Text> paints a highlight, which changes the |
| 359 | + * painted string but not the layout. Rebuilding the text view in the middle of |
| 360 | + * that touch destroys the long press that UIKit has already started, and the |
| 361 | + * paragraph never selects. Only a layout change may rebuild it. |
| 362 | + */ |
| 363 | +- (void)testHighlightOnlyUpdateKeepsTheSameSelectionTextView |
| 364 | +{ |
| 365 | + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; |
| 366 | + UITextView *before = [self selectionTextViewIn:view]; |
| 367 | + XCTAssertNotNil(before); |
| 368 | + |
| 369 | + [view updateState:[self stateWithAttributedString:[self decoratedAttributedStringHighlighted:YES]] oldState:nil]; |
| 370 | + [view layoutIfNeeded]; |
| 371 | + |
| 372 | + XCTAssertEqual( |
| 373 | + [self selectionTextViewIn:view], before, @"A pressed highlight must not rebuild the selection text view."); |
| 374 | +} |
| 375 | + |
| 376 | +/* |
| 377 | + * The cache must not go too far the other way: different text lays out |
| 378 | + * differently, so it has to produce a new text view. |
| 379 | + */ |
| 380 | +- (void)testTextChangeRebuildsTheSelectionTextView |
| 381 | +{ |
| 382 | + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; |
| 383 | + UITextView *before = [self selectionTextViewIn:view]; |
| 384 | + XCTAssertNotNil(before); |
| 385 | + |
| 386 | + [view updateState:[self stateWithAttributedString:[self attributedStringWithText:"A different paragraph"]] |
| 387 | + oldState:nil]; |
| 388 | + [view layoutIfNeeded]; |
| 389 | + |
| 390 | + XCTAssertNotEqual([self selectionTextViewIn:view], before, @"New text must rebuild the selection text view."); |
| 391 | +} |
| 392 | + |
| 393 | +#pragma mark - A showing selection owns the touch |
| 394 | + |
| 395 | +/* |
| 396 | + * A drag of a selection handle starts on the glyphs the handle sits on, which |
| 397 | + * is often a pressable <Text>. Delivering that touch presses the link, so |
| 398 | + * adjusting a selection follows it. While a selection shows, the paragraph |
| 399 | + * must hand out no event emitter at all. |
| 400 | + */ |
| 401 | +- (void)testShowingSelectionGivesNoEventEmitter |
| 402 | +{ |
| 403 | + auto eventEmitter = std::make_shared<TouchEventEmitter>(nullptr, EventDispatcher::Weak{}); |
| 404 | + RCTParagraphComponentView *view = [self paragraphViewWithEventEmitter:eventEmitter]; |
| 405 | + UITextView *selectionTextView = [self selectionTextViewIn:view]; |
| 406 | + XCTAssertNotNil(selectionTextView); |
| 407 | + |
| 408 | + selectionTextView.selectedRange = NSMakeRange(0, 5); |
| 409 | + |
| 410 | + XCTAssertTrue( |
| 411 | + [(id<RCTTouchableComponentViewProtocol>)view touchEventEmitterAtPoint:CGPointMake(100, 10)] == nullptr, |
| 412 | + @"A touch that lands while a selection shows must not press the text under it."); |
| 413 | +} |
| 414 | + |
| 415 | +/* |
| 416 | + * The guard must not reach an ordinary tap. With nothing selected the pressable |
| 417 | + * fragment still gets its press. |
| 418 | + */ |
| 419 | +- (void)testWithoutASelectionThePressStillGoesThrough |
| 420 | +{ |
| 421 | + auto eventEmitter = std::make_shared<TouchEventEmitter>(nullptr, EventDispatcher::Weak{}); |
| 422 | + RCTParagraphComponentView *view = [self paragraphViewWithEventEmitter:eventEmitter]; |
| 423 | + UITextView *selectionTextView = [self selectionTextViewIn:view]; |
| 424 | + XCTAssertNotNil(selectionTextView); |
| 425 | + |
| 426 | + selectionTextView.selectedRange = NSMakeRange(0, 0); |
| 427 | + |
| 428 | + XCTAssertEqual( |
| 429 | + (const void *)[(id<RCTTouchableComponentViewProtocol>)view touchEventEmitterAtPoint:CGPointMake(100, 10)].get(), |
| 430 | + (const void *)eventEmitter.get(), |
| 431 | + @"With nothing selected, a press must still reach the pressable text."); |
| 432 | +} |
| 433 | + |
294 | 434 | #pragma mark - Accessibility |
295 | 435 |
|
296 | 436 | /* |
|
0 commit comments