diff --git a/engine/src/flutter/lib/ui/semantics.dart b/engine/src/flutter/lib/ui/semantics.dart index bb9d586e41..59052b157b 100644 --- a/engine/src/flutter/lib/ui/semantics.dart +++ b/engine/src/flutter/lib/ui/semantics.dart @@ -45,6 +45,7 @@ class SemanticsAction { static const int _kMoveCursorBackwardByWordIndex = 1 << 20; static const int _kSetTextIndex = 1 << 21; static const int _kFocusIndex = 1 << 22; + static const int _kScrollToOffsetIndex = 1 << 23; // READ THIS: if you add an action here, you MUST update the // numSemanticsActions value in testing/dart/semantics_test.dart and // lib/web_ui/test/engine/semantics/semantics_api_test.dart, or tests @@ -86,6 +87,17 @@ class SemanticsAction { /// scrollable. static const SemanticsAction scrollDown = SemanticsAction._(_kScrollDownIndex, 'scrollDown'); + /// A request to scroll the scrollable container to a given scroll offset. + /// + /// The payload of this [SemanticsAction] is a flutter-standard-encoded + /// [Float64List] of length 2 containing the target horizontal and vertical + /// offsets (in logical pixels) the receiving scrollable container should + /// scroll to. + /// + /// This action is used by iOS Full Keyboard Access to reveal contents that + /// are currently not visible in the viewport. + static const SemanticsAction scrollToOffset = SemanticsAction._(_kScrollToOffsetIndex, 'scrollToOffset'); + /// A request to increase the value represented by the semantics node. /// /// For example, this action might be recognized by a slider control. @@ -265,6 +277,7 @@ class SemanticsAction { _kScrollRightIndex: scrollRight, _kScrollUpIndex: scrollUp, _kScrollDownIndex: scrollDown, + _kScrollToOffsetIndex: scrollToOffset, _kIncreaseIndex: increase, _kDecreaseIndex: decrease, _kShowOnScreenIndex: showOnScreen, @@ -764,7 +777,7 @@ base class LocaleStringAttribute extends StringAttribute { _initLocaleStringAttribute(this, range.start, range.end, locale.toLanguageTag()); } - /// The lanuage of this attribute. + /// The language of this attribute. final Locale locale; @Native(symbol: 'NativeStringAttribute::initLocaleStringAttribute') diff --git a/engine/src/flutter/lib/ui/semantics/semantics_node.h b/engine/src/flutter/lib/ui/semantics/semantics_node.h index 98d84c6c6a..0f02ff3202 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_node.h +++ b/engine/src/flutter/lib/ui/semantics/semantics_node.h @@ -43,6 +43,7 @@ enum class SemanticsAction : int32_t { kMoveCursorBackwardByWord = 1 << 20, kSetText = 1 << 21, kFocus = 1 << 22, + kScrollToOffset = 1 << 23, }; const int kVerticalScrollSemanticsActions = diff --git a/engine/src/flutter/lib/web_ui/lib/semantics.dart b/engine/src/flutter/lib/web_ui/lib/semantics.dart index be36411520..786eaacc54 100644 --- a/engine/src/flutter/lib/web_ui/lib/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/semantics.dart @@ -33,6 +33,7 @@ class SemanticsAction { static const int _kMoveCursorBackwardByWordIndex = 1 << 20; static const int _kSetTextIndex = 1 << 21; static const int _kFocusIndex = 1 << 22; + static const int _kScrollToOffsetIndex = 1 << 23; static const SemanticsAction tap = SemanticsAction._(_kTapIndex, 'tap'); static const SemanticsAction longPress = SemanticsAction._(_kLongPressIndex, 'longPress'); @@ -40,6 +41,7 @@ class SemanticsAction { static const SemanticsAction scrollRight = SemanticsAction._(_kScrollRightIndex, 'scrollRight'); static const SemanticsAction scrollUp = SemanticsAction._(_kScrollUpIndex, 'scrollUp'); static const SemanticsAction scrollDown = SemanticsAction._(_kScrollDownIndex, 'scrollDown'); + static const SemanticsAction scrollToOffset = SemanticsAction._(_kScrollToOffsetIndex, 'scrollToOffset'); static const SemanticsAction increase = SemanticsAction._(_kIncreaseIndex, 'increase'); static const SemanticsAction decrease = SemanticsAction._(_kDecreaseIndex, 'decrease'); static const SemanticsAction showOnScreen = SemanticsAction._(_kShowOnScreenIndex, 'showOnScreen'); @@ -65,6 +67,7 @@ class SemanticsAction { _kScrollRightIndex: scrollRight, _kScrollUpIndex: scrollUp, _kScrollDownIndex: scrollDown, + _kScrollToOffsetIndex: scrollToOffset, _kIncreaseIndex: increase, _kDecreaseIndex: decrease, _kShowOnScreenIndex: showOnScreen, diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_api_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_api_test.dart index 8df41a7cae..e56242639b 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_api_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_api_test.dart @@ -29,7 +29,7 @@ void testMain() { }); // This must match the number of actions in lib/ui/semantics.dart - const int numSemanticsActions = 23; + const int numSemanticsActions = 24; test('SemanticsAction.values refers to all actions.', () async { expect(SemanticsAction.values.length, equals(numSemanticsActions)); for (int index = 0; index < numSemanticsActions; ++index) { diff --git a/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java index 0f28573023..51d2070929 100644 --- a/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -2120,7 +2120,8 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { MOVE_CURSOR_FORWARD_BY_WORD(1 << 19), MOVE_CURSOR_BACKWARD_BY_WORD(1 << 20), SET_TEXT(1 << 21), - FOCUS(1 << 22); + FOCUS(1 << 22), + SCROLL_TO_OFFSET(1 << 23); public final int value; diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.h index 28b9b56362..e5f2f6343b 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.h +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.h @@ -18,10 +18,30 @@ NS_ASSUME_NONNULL_BEGIN * sends all of selector calls from accessibility services to the * owner SemanticsObject. */ -@interface FlutterSemanticsScrollView : UIScrollView +@interface FlutterSemanticsScrollView : UIScrollView @property(nonatomic, weak, nullable) SemanticsObject* semanticsObject; +/// Whether this scroll view's content offset is actively being updated by UIKit +/// or other the system services. +/// +/// This flag is set by the `FlutterSemanticsScrollView` itself, typically in +/// one of the `UIScrollViewDelegate` methods. +/// +/// When this flag is true, the `SemanticsObject` implementation ignores all +/// content offset updates coming from the Flutter framework, to prevent +/// potential feedback loops (especially when the framework is only echoing +/// the new content offset back to this scroll view). +/// +/// For example, to scroll a scrollable container with iOS full keyboard access, +/// the iOS focus system uses a display link to scroll the container to the +/// desired offset animatedly. If the user changes the scroll offset during the +/// animation, the display link will be invalidated and the scrolling animation +/// will be interrupted. For simplicity, content offset updates coming from the +/// framework will be ignored in the relatively short animation duration (~1s), +/// allowing the scrolling animation to finish. +@property(nonatomic, readonly) BOOL isDoingSystemScrolling; + - (instancetype)init NS_UNAVAILABLE; - (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE; - (instancetype)initWithCoder:(NSCoder*)coder NS_UNAVAILABLE; diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.mm index 9b74e2ddab..c6952c72b7 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.mm @@ -15,6 +15,8 @@ FLUTTER_ASSERT_ARC self = [super initWithFrame:CGRectZero]; if (self) { _semanticsObject = semanticsObject; + _isDoingSystemScrolling = NO; + self.delegate = self; } return self; } @@ -105,4 +107,14 @@ FLUTTER_ASSERT_ARC return self.semanticsObject.children.count; } +- (void)scrollViewWillEndDragging:(UIScrollView*)scrollView + withVelocity:(CGPoint)velocity + targetContentOffset:(inout CGPoint*)targetContentOffset { + _isDoingSystemScrolling = YES; +} + +- (void)scrollViewDidEndDecelerating:(UIScrollView*)scrollView { + _isDoingSystemScrolling = NO; +} + @end diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject+UIFocusSystem.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject+UIFocusSystem.mm index 3448b92805..c3262de538 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject+UIFocusSystem.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject+UIFocusSystem.mm @@ -3,7 +3,10 @@ // found in the LICENSE file. #import "SemanticsObject.h" +#include "flutter/lib/ui/semantics/semantics_node.h" +#import "flutter/shell/platform/darwin/common/framework/Headers/FlutterCodecs.h" #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.h" FLUTTER_ASSERT_ARC @@ -27,10 +30,19 @@ FLUTTER_ASSERT_ARC // translated to calls such as -[NSObject accessibilityActivate]), while most // other key events are dispatched to the framework. @interface SemanticsObject (UIFocusSystem) +/// The `UIFocusItem` that represents this SemanticsObject. +/// +/// For regular `SemanticsObject`s, this method returns `self`, +/// for `FlutterScrollableSemanticsObject`s, this method returns its scroll view. +- (id)focusItem; @end @implementation SemanticsObject (UIFocusSystem) +- (id)focusItem { + return self; +} + #pragma mark - UIFocusEnvironment Conformance - (void)setNeedsFocusUpdate { @@ -49,7 +61,7 @@ FLUTTER_ASSERT_ARC - (id)parentFocusEnvironment { // The root SemanticsObject node's parent is the FlutterView. - return self.parent ?: self.bridge->view(); + return self.parent.focusItem ?: self.bridge->view(); } - (NSArray>*)preferredFocusEnvironments { @@ -71,8 +83,57 @@ FLUTTER_ASSERT_ARC return self.node.HasAction(flutter::SemanticsAction::kTap); } +// The frame is described in the `coordinateSpace` of the +// `parentFocusEnvironment` (all `parentFocusEnvironment`s are `UIFocusItem`s). +// +// See also the `coordinateSpace` implementation. +// TODO(LongCatIsLooong): use CoreGraphics types. - (CGRect)frame { - return self.accessibilityFrame; + SkPoint quad[4] = {SkPoint::Make(self.node.rect.left(), self.node.rect.top()), + SkPoint::Make(self.node.rect.left(), self.node.rect.bottom()), + SkPoint::Make(self.node.rect.right(), self.node.rect.top()), + SkPoint::Make(self.node.rect.right(), self.node.rect.bottom())}; + + SkM44 transform = self.node.transform; + FlutterSemanticsScrollView* scrollView; + for (SemanticsObject* ancestor = self.parent; ancestor; ancestor = ancestor.parent) { + if ([ancestor isKindOfClass:[FlutterScrollableSemanticsObject class]]) { + scrollView = ((FlutterScrollableSemanticsObject*)ancestor).scrollView; + break; + } + transform = ancestor.node.transform * transform; + } + + for (auto& vertex : quad) { + SkV4 vector = transform.map(vertex.x(), vertex.y(), 0, 1); + vertex = SkPoint::Make(vector.x / vector.w, vector.y / vector.w); + } + + SkRect rect; + rect.setBounds(quad, 4); + // If this UIFocusItemContainer's coordinateSpace is a UIScrollView, offset + // the rect by `contentOffset` because the contentOffset translation is + // incorporated into the paint transform at different node depth in UIKit + // and Flutter. In Flutter, the translation is added to the cells + // while in UIKit the viewport's bounds is manipulated (IOW, each cell's frame + // in the UIScrollView coordinateSpace does not change when the UIScrollView + // scrolls). + CGRect unscaledRect = + CGRectMake(rect.x() + scrollView.bounds.origin.x, rect.y() + scrollView.bounds.origin.y, + rect.width(), rect.height()); + if (scrollView) { + return unscaledRect; + } + // `rect` could be in physical pixels since the root RenderObject ("RenderView") + // applies a transform that turns logical pixels to physical pixels. Undo the + // transform by dividing the coordinates by the screen's scale factor, if this + // UIFocusItem's reported `coordinateSpace` is the root view (which means this + // UIFocusItem is not inside of a scroll view). + // + // Screen can be nil if the FlutterView is covered by another native view. + CGFloat scale = (self.bridge->view().window.screen ?: UIScreen.mainScreen).scale; + return CGRectMake(unscaledRect.origin.x / scale, unscaledRect.origin.y / scale, + unscaledRect.size.width / scale, unscaledRect.size.height / scale); } #pragma mark - UIFocusItemContainer Conformance @@ -87,16 +148,94 @@ FLUTTER_ASSERT_ARC // // This method is only supposed to return items within the given // rect but returning everything in the subtree seems to work fine. - NSMutableArray* reversedItems = + NSMutableArray>* reversedItems = [[NSMutableArray alloc] initWithCapacity:self.childrenInHitTestOrder.count]; for (NSUInteger i = 0; i < self.childrenInHitTestOrder.count; ++i) { - [reversedItems - addObject:self.childrenInHitTestOrder[self.childrenInHitTestOrder.count - 1 - i]]; + SemanticsObject* child = self.childrenInHitTestOrder[self.childrenInHitTestOrder.count - 1 - i]; + [reversedItems addObject:child.focusItem]; } return reversedItems; } - (id)coordinateSpace { - return self.bridge->view(); + // A regular SemanticsObject uses the same coordinate space as its parent. + return self.parent.coordinateSpace ?: self.bridge->view(); +} + +@end + +/// Scrollable containers interact with the iOS focus engine using the +/// `UIFocusItemScrollableContainer` protocol. The said protocol (and other focus-related protocols) +/// does not provide means to inform the focus system of layout changes. In order for the focus +/// highlight to update properly as the scroll view scrolls, this implementation incorporates a +/// UIScrollView into the focus hierarchy to workaround the highlight update problem. +/// +/// As a result, in the current implementation only scrollable containers and the root node +/// establish their own `coordinateSpace`s. All other `UIFocusItemContainter`s use the same +/// `coordinateSpace` as the containing UIScrollView, or the root `FlutterView`, whichever is +/// closer. +/// +/// See also the `frame` method implementation. +#pragma mark - Scrolling + +@interface FlutterScrollableSemanticsObject (CoordinateSpace) +@end + +@implementation FlutterScrollableSemanticsObject (CoordinateSpace) +- (id)coordinateSpace { + // A scrollable SemanticsObject uses the same coordinate space as the scroll view. + // This may not work very well in nested scroll views. + return self.scrollView; +} + +- (id)focusItem { + return self.scrollView; +} + +@end + +@interface FlutterSemanticsScrollView (UIFocusItemScrollableContainer) < + UIFocusItemScrollableContainer> +@end + +@implementation FlutterSemanticsScrollView (UIFocusItemScrollableContainer) + +#pragma mark - FlutterSemanticsScrollView UIFocusItemScrollableContainer Conformance + +- (CGSize)visibleSize { + return self.frame.size; +} + +- (void)setContentOffset:(CGPoint)contentOffset { + [super setContentOffset:contentOffset]; + // Do no send flutter::SemanticsAction::kScrollToOffset if it's triggered + // by a framework update. + if (![self.semanticsObject isAccessibilityBridgeAlive] || !self.isDoingSystemScrolling) { + return; + } + + double offset[2] = {contentOffset.x, contentOffset.y}; + FlutterStandardTypedData* offsetData = [FlutterStandardTypedData + typedDataWithFloat64:[NSData dataWithBytes:&offset length:sizeof(offset)]]; + NSData* encoded = [[FlutterStandardMessageCodec sharedInstance] encode:offsetData]; + self.semanticsObject.bridge->DispatchSemanticsAction( + self.semanticsObject.uid, flutter::SemanticsAction::kScrollToOffset, + fml::MallocMapping::Copy(encoded.bytes, encoded.length)); +} + +- (BOOL)canBecomeFocused { + return NO; +} + +- (id)parentFocusEnvironment { + return self.semanticsObject.parentFocusEnvironment; +} + +- (NSArray>*)preferredFocusEnvironments { + return nil; +} + +- (NSArray>*)focusItemsInRect:(CGRect)rect { + return [self.semanticsObject focusItemsInRect:rect]; } @end diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.h index d3441ec005..2c9e051611 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.h +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.h @@ -10,6 +10,7 @@ #include "flutter/fml/macros.h" #include "flutter/fml/memory/weak_ptr.h" #include "flutter/lib/ui/semantics/semantics_node.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.h" #import "flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge_ios.h" constexpr int32_t kRootNodeId = 0; @@ -186,7 +187,7 @@ constexpr float kScrollExtentMaxForInf = 1000; /// The semantics object for scrollable. This class creates an UIScrollView to interact with the /// iOS. @interface FlutterScrollableSemanticsObject : SemanticsObject - +@property(nonatomic, readonly) FlutterSemanticsScrollView* scrollView; @end /** diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm index 8212c2fd01..39882196e8 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm @@ -154,6 +154,8 @@ CGRect ConvertRectToGlobal(SemanticsObject* reference, CGRect local_rect) { _scrollView = [[FlutterSemanticsScrollView alloc] initWithSemanticsObject:self]; [_scrollView setShowsHorizontalScrollIndicator:NO]; [_scrollView setShowsVerticalScrollIndicator:NO]; + [_scrollView setContentInset:UIEdgeInsetsZero]; + [_scrollView setContentInsetAdjustmentBehavior:UIScrollViewContentInsetAdjustmentNever]; [self.bridge->view() addSubview:_scrollView]; } return self; @@ -174,7 +176,10 @@ CGRect ConvertRectToGlobal(SemanticsObject* reference, CGRect local_rect) { // contentOffset is 0.0, only the scroll down action is available. self.scrollView.frame = self.accessibilityFrame; self.scrollView.contentSize = [self contentSizeInternal]; - [self.scrollView setContentOffset:[self contentOffsetInternal] animated:NO]; + // See the documentation on `isDoingSystemScrolling`. + if (!self.scrollView.isDoingSystemScrolling) { + [self.scrollView setContentOffset:self.contentOffsetInternal animated:NO]; + } } - (id)nativeAccessibility { diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm index ff514b6730..02fb11f983 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm @@ -7,6 +7,7 @@ #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTouchInterceptingView_Test.h" #import "flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.h" #import "flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTestMocks.h" @@ -19,6 +20,10 @@ const float kFloatCompareEpsilon = 0.001; @interface SemanticsObject (UIFocusSystem) @end +@interface FlutterScrollableSemanticsObject (UIFocusItemScrollableContainer) < + UIFocusItemScrollableContainer> +@end + @interface TextInputSemanticsObject (Test) - (UIView*)textInputSurrogate; @end @@ -665,7 +670,7 @@ const float kFloatCompareEpsilon = 0.001; XCTAssertEqual(container.semanticsObject, parentObject); } -- (void)testFlutterScrollableSemanticsObjectHidesScrollBar { +- (void)testFlutterScrollableSemanticsObjectNoScrollBarOrContentInsets { fml::WeakPtrFactory factory( new flutter::testing::MockAccessibilityBridge()); fml::WeakPtr bridge = factory.GetWeakPtr(); @@ -685,6 +690,9 @@ const float kFloatCompareEpsilon = 0.001; XCTAssertFalse(scrollView.showsHorizontalScrollIndicator); XCTAssertFalse(scrollView.showsVerticalScrollIndicator); + XCTAssertEqual(scrollView.contentInsetAdjustmentBehavior, + UIScrollViewContentInsetAdjustmentNever); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(scrollView.contentInset, UIEdgeInsetsZero)); } - (void)testSemanticsObjectBuildsAttributedString { @@ -1155,6 +1163,19 @@ const float kFloatCompareEpsilon = 0.001; [self waitForExpectationsWithTimeout:1 handler:nil]; } +- (void)testSliderSemanticsObject { + fml::WeakPtrFactory factory( + new flutter::testing::MockAccessibilityBridge()); + fml::WeakPtr bridge = factory.GetWeakPtr(); + + flutter::SemanticsNode node; + node.flags = static_cast(flutter::SemanticsFlags::kIsSlider); + SemanticsObject* object = [[SemanticsObject alloc] initWithBridge:bridge uid:0]; + [object setSemanticsNode:&node]; + [object accessibilityBridgeDidFinishUpdate]; + XCTAssertEqual([object accessibilityActivate], YES); +} + - (void)testUIFocusItemConformance { fml::WeakPtrFactory factory( new flutter::testing::MockAccessibilityBridge()); @@ -1207,17 +1228,57 @@ const float kFloatCompareEpsilon = 0.001; XCTAssertTrue([itemsInRect containsObject:child2]); } -- (void)testSliderSemanticsObject { +- (void)testUIFocusItemScrollableContainerConformance { fml::WeakPtrFactory factory( new flutter::testing::MockAccessibilityBridge()); - fml::WeakPtr bridge = factory.GetWeakPtr(); + fml::WeakPtr bridge = factory.GetWeakPtr(); + FlutterScrollableSemanticsObject* scrollable = + [[FlutterScrollableSemanticsObject alloc] initWithBridge:bridge uid:5]; - flutter::SemanticsNode node; - node.flags = static_cast(flutter::SemanticsFlags::kIsSlider); - SemanticsObject* object = [[SemanticsObject alloc] initWithBridge:bridge uid:0]; - [object setSemanticsNode:&node]; - [object accessibilityBridgeDidFinishUpdate]; - XCTAssertEqual([object accessibilityActivate], YES); + // setContentOffset + CGPoint p = CGPointMake(123.0, 456.0); + [scrollable.scrollView scrollViewWillEndDragging:scrollable.scrollView + withVelocity:CGPointZero + targetContentOffset:&p]; + scrollable.scrollView.contentOffset = p; + [scrollable.scrollView scrollViewDidEndDecelerating:scrollable.scrollView]; + XCTAssertEqual(bridge->observations.size(), (size_t)1); + XCTAssertEqual(bridge->observations[0].id, 5); + XCTAssertEqual(bridge->observations[0].action, flutter::SemanticsAction::kScrollToOffset); + + std::vector args = bridge->observations[0].args; + XCTAssertEqual(args.size(), 3 * sizeof(CGFloat)); + + NSData* encoded = [NSData dataWithBytes:args.data() length:args.size()]; + FlutterStandardTypedData* decoded = [[FlutterStandardMessageCodec sharedInstance] decode:encoded]; + CGPoint point = CGPointZero; + memcpy(&point, decoded.data.bytes, decoded.data.length); + XCTAssertTrue(CGPointEqualToPoint(point, p)); } +- (void)testUIFocusItemScrollableContainerNoFeedbackLoops { + fml::WeakPtrFactory factory( + new flutter::testing::MockAccessibilityBridge()); + fml::WeakPtr bridge = factory.GetWeakPtr(); + FlutterScrollableSemanticsObject* scrollable = + [[FlutterScrollableSemanticsObject alloc] initWithBridge:bridge uid:5]; + + // setContentOffset + const CGPoint p = CGPointMake(0.0, 456.0); + scrollable.scrollView.contentOffset = p; + bridge->observations.clear(); + + const SkScalar scrollPosition = p.y + 0.0000000000000001; + flutter::SemanticsNode node; + node.flags = static_cast(flutter::SemanticsFlags::kHasImplicitScrolling); + node.actions = flutter::kVerticalScrollSemanticsActions; + node.rect = SkRect::MakeXYWH(0, 0, 100, 200); + node.scrollExtentMax = 10000; + node.scrollPosition = scrollPosition; + node.transform = {1.0, 0, 0, 0, 0, 1.0, 0, 0, 0, 0, 1.0, 0, 0, scrollPosition, 0, 1.0}; + [scrollable setSemanticsNode:&node]; + [scrollable accessibilityBridgeDidFinishUpdate]; + + XCTAssertEqual(bridge->observations.size(), (size_t)0); +} @end diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTestMocks.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTestMocks.h index 562c471e93..a740349ab1 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTestMocks.h +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTestMocks.h @@ -15,10 +15,18 @@ namespace testing { class SemanticsActionObservation { public: SemanticsActionObservation(int32_t observed_id, SemanticsAction observed_action) - : id(observed_id), action(observed_action) {} + : id(observed_id), action(observed_action), args({}) {} + + SemanticsActionObservation(int32_t observed_id, + SemanticsAction observed_action, + fml::MallocMapping& args) + : id(observed_id), + action(observed_action), + args(args.GetMapping(), args.GetMapping() + args.GetSize()) {} int32_t id; SemanticsAction action; + std::vector args; }; class MockAccessibilityBridge : public AccessibilityBridgeIos { @@ -38,7 +46,7 @@ class MockAccessibilityBridge : public AccessibilityBridgeIos { void DispatchSemanticsAction(int32_t id, SemanticsAction action, fml::MallocMapping args) override { - SemanticsActionObservation observation(id, action); + SemanticsActionObservation observation(id, action, args); observations.push_back(observation); } void AccessibilityObjectDidBecomeFocused(int32_t id) override {} @@ -67,7 +75,7 @@ class MockAccessibilityBridgeNoWindow : public AccessibilityBridgeIos { void DispatchSemanticsAction(int32_t id, SemanticsAction action, fml::MallocMapping args) override { - SemanticsActionObservation observation(id, action); + SemanticsActionObservation observation(id, action, args); observations.push_back(observation); } void AccessibilityObjectDidBecomeFocused(int32_t id) override {} diff --git a/engine/src/flutter/shell/platform/embedder/embedder.h b/engine/src/flutter/shell/platform/embedder/embedder.h index f0df9973f4..8ca474222d 100644 --- a/engine/src/flutter/shell/platform/embedder/embedder.h +++ b/engine/src/flutter/shell/platform/embedder/embedder.h @@ -164,6 +164,9 @@ typedef enum { kFlutterSemanticsActionSetText = 1 << 21, /// Request that the respective focusable widget gain input focus. kFlutterSemanticsActionFocus = 1 << 22, + /// Request that scrolls the current scrollable container to a given scroll + /// offset. + kFlutterSemanticsActionScrollToOffset = 1 << 23, } FlutterSemanticsAction; /// The set of properties that may be associated with a semantics node. diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/accessibility_bridge.cc b/engine/src/flutter/shell/platform/fuchsia/flutter/accessibility_bridge.cc index b9f443035a..987b9303ba 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/accessibility_bridge.cc +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/accessibility_bridge.cc @@ -161,6 +161,9 @@ std::string NodeActionsToString(const flutter::SemanticsNode& node) { if (node.HasAction(flutter::SemanticsAction::kScrollUp)) { output += "kScrollUp|"; } + if (node.HasAction(flutter::SemanticsAction::kScrollToOffset)) { + output += "kScrollToOffset|"; + } if (node.HasAction(flutter::SemanticsAction::kSetSelection)) { output += "kSetSelection|"; } diff --git a/engine/src/flutter/testing/dart/semantics_test.dart b/engine/src/flutter/testing/dart/semantics_test.dart index a5841e7d1d..8b2f889210 100644 --- a/engine/src/flutter/testing/dart/semantics_test.dart +++ b/engine/src/flutter/testing/dart/semantics_test.dart @@ -22,7 +22,7 @@ void main() { }); // This must match the number of actions in lib/ui/semantics.dart - const int numSemanticsActions = 23; + const int numSemanticsActions = 24; test('SemanticsAction.values refers to all actions.', () async { expect(SemanticsAction.values.length, equals(numSemanticsActions)); for (int index = 0; index < numSemanticsActions; ++index) {