1// Copyright 2013 The Flutter Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5part of engine; 6 7/// Implements vertical and horizontal scrolling functionality for semantics 8/// objects. 9/// 10/// Scrolling is implemented using a "joystick" method. The absolute value of 11/// "scrollTop" in HTML is not important. We only need to know in whether the 12/// value changed in the positive or negative direction. If it changes in the 13/// positive direction we send a [ui.SemanticsAction.scrollUp]. Otherwise, we 14/// send [ui.SemanticsAction.scrollDown]. The actual scrolling is then handled 15/// by the framework and we receive a [ui.SemanticsUpdate] containing the new 16/// [scrollPosition] and child positions. 17/// 18/// "scrollTop" or "scrollLeft" is always reset to an arbitrarily chosen non- 19/// zero "neutral" scroll position value. This is done so we have a 20/// predictable range of DOM scroll position values. When the amount of 21/// contents is less than the size of the viewport the browser snaps 22/// "scrollTop" back to zero. If there is more content than available in the 23/// viewport "scrollTop" may take positive values. 24class Scrollable extends RoleManager { 25 Scrollable(SemanticsObject semanticsObject) 26 : super(Role.scrollable, semanticsObject); 27 28 /// Disables browser-driven scrolling in the presence of pointer events. 29 GestureModeCallback _gestureModeListener; 30 31 /// Listens to HTML "scroll" gestures detected by the browser. 32 /// 33 /// This gesture is converted to [ui.SemanticsAction.scrollUp] or 34 /// [ui.SemanticsAction.scrollDown], depending on the direction. 35 html.EventListener _scrollListener; 36 37 /// The value of the "scrollTop" or "scrollLeft" property of this object's 38 /// [element] that has zero offset relative to the [scrollPosition]. 39 int _effectiveNeutralScrollPosition = 0; 40 41 /// Responds to browser-detected "scroll" gestures. 42 void _recomputeScrollPosition() { 43 if (_domScrollPosition != _effectiveNeutralScrollPosition) { 44 if (!semanticsObject.owner.shouldAcceptBrowserGesture('scroll')) { 45 return; 46 } 47 final bool doScrollForward = 48 _domScrollPosition > _effectiveNeutralScrollPosition; 49 _neutralizeDomScrollPosition(); 50 semanticsObject.recomputePositionAndSize(); 51 52 final int semanticsId = semanticsObject.id; 53 if (doScrollForward) { 54 if (semanticsObject.isVerticalScrollContainer) { 55 ui.window.onSemanticsAction( 56 semanticsId, ui.SemanticsAction.scrollUp, null); 57 } else { 58 assert(semanticsObject.isHorizontalScrollContainer); 59 ui.window.onSemanticsAction( 60 semanticsId, ui.SemanticsAction.scrollLeft, null); 61 } 62 } else { 63 if (semanticsObject.isVerticalScrollContainer) { 64 ui.window.onSemanticsAction( 65 semanticsId, ui.SemanticsAction.scrollDown, null); 66 } else { 67 assert(semanticsObject.isHorizontalScrollContainer); 68 ui.window.onSemanticsAction( 69 semanticsId, ui.SemanticsAction.scrollRight, null); 70 } 71 } 72 } 73 } 74 75 @override 76 void update() { 77 if (_scrollListener == null) { 78 // We need to set touch-action:none explicitly here, despite the fact 79 // that we already have it on the <body> tag because overflow:scroll 80 // still causes the browser to take over pointer events in order to 81 // process scrolling. We don't want that when scrolling is handled by 82 // the framework. 83 // 84 // This is effective only in Chrome. Safari does not implement this 85 // CSS property. In Safari the `PointerBinding` uses `preventDefault` 86 // to prevent browser scrolling. 87 semanticsObject.element.style.touchAction = 'none'; 88 _gestureModeDidChange(); 89 90 // We neutralize the scroll position after all children have been 91 // updated. Otherwise the browser does not yet have the sizes of the 92 // child nodes and resets the scrollTop value back to zero. 93 semanticsObject.owner.addOneTimePostUpdateCallback(() { 94 _neutralizeDomScrollPosition(); 95 }); 96 97 // Memoize the tear-off because Dart does not guarantee that two 98 // tear-offs of a method on the same instance will produce the same 99 // object. 100 _gestureModeListener = (_) { 101 _gestureModeDidChange(); 102 }; 103 semanticsObject.owner.addGestureModeListener(_gestureModeListener); 104 105 _scrollListener = (_) { 106 _recomputeScrollPosition(); 107 }; 108 semanticsObject.element.addEventListener('scroll', _scrollListener); 109 } 110 } 111 112 /// The value of "scrollTop" or "scrollLeft", depending on the scroll axis. 113 int get _domScrollPosition { 114 if (semanticsObject.isVerticalScrollContainer) { 115 return semanticsObject.element.scrollTop; 116 } else { 117 assert(semanticsObject.isHorizontalScrollContainer); 118 return semanticsObject.element.scrollLeft; 119 } 120 } 121 122 /// Resets the scroll position (top or left) to the neutral value. 123 /// 124 /// The scroll position of the scrollable HTML node that's considered to 125 /// have zero offset relative to Flutter's notion of scroll position is 126 /// referred to as "neutral scroll position". 127 /// 128 /// We always set the the scroll position to a non-zero value in order to 129 /// be able to scroll in the negative direction. When scrollTop/scrollLeft is 130 /// zero the browser will refuse to scroll back even when there is more 131 /// content available. 132 void _neutralizeDomScrollPosition() { 133 // This value is arbitrary. 134 const int _canonicalNeutralScrollPosition = 10; 135 136 final html.Element element = semanticsObject.element; 137 if (semanticsObject.isVerticalScrollContainer) { 138 element.scrollTop = _canonicalNeutralScrollPosition; 139 // Read back because the effective value depends on the amount of content. 140 _effectiveNeutralScrollPosition = element.scrollTop; 141 semanticsObject 142 ..verticalContainerAdjustment = 143 _effectiveNeutralScrollPosition.toDouble() 144 ..horizontalContainerAdjustment = 0.0; 145 } else { 146 element.scrollLeft = _canonicalNeutralScrollPosition; 147 // Read back because the effective value depends on the amount of content. 148 _effectiveNeutralScrollPosition = element.scrollLeft; 149 semanticsObject 150 ..verticalContainerAdjustment = 0.0 151 ..horizontalContainerAdjustment = 152 _effectiveNeutralScrollPosition.toDouble(); 153 } 154 } 155 156 void _gestureModeDidChange() { 157 final html.Element element = semanticsObject.element; 158 switch (semanticsObject.owner.gestureMode) { 159 case GestureMode.browserGestures: 160 // overflow:scroll will cause the browser report "scroll" events when 161 // the accessibility focus shifts outside the visible bounds. 162 // 163 // Note that on Android overflow:hidden also works. However, we prefer 164 // "scroll" because it works both on Android and iOS. 165 if (semanticsObject.isVerticalScrollContainer) { 166 element.style.overflowY = 'scroll'; 167 } else { 168 assert(semanticsObject.isHorizontalScrollContainer); 169 element.style.overflowX = 'scroll'; 170 } 171 break; 172 case GestureMode.pointerEvents: 173 // We use "hidden" instead of "scroll" so that the browser does 174 // not "steal" pointer events. Flutter gesture recognizers need 175 // all pointer events in order to recognize gestures correctly. 176 if (semanticsObject.isVerticalScrollContainer) { 177 element.style.overflowY = 'hidden'; 178 } else { 179 assert(semanticsObject.isHorizontalScrollContainer); 180 element.style.overflowX = 'hidden'; 181 } 182 break; 183 } 184 } 185 186 @override 187 void dispose() { 188 final html.CssStyleDeclaration style = semanticsObject.element.style; 189 assert(_gestureModeListener != null); 190 style.removeProperty('overflowY'); 191 style.removeProperty('overflowX'); 192 style.removeProperty('touch-action'); 193 if (_scrollListener != null) { 194 semanticsObject.element.removeEventListener('scroll', _scrollListener); 195 } 196 semanticsObject.owner.removeGestureModeListener(_gestureModeListener); 197 _gestureModeListener = null; 198 } 199} 200