@@ -39,7 +39,8 @@ abstract class ResizeSensorPropsMixin {
3939 static final ResizeSensorPropsMixinMapView defaultProps = new ResizeSensorPropsMixinMapView ({})
4040 ..isFlexChild = false
4141 ..isFlexContainer = false
42- ..shrink = false ;
42+ ..shrink = false
43+ ..quickMount = false ;
4344
4445 Map get props;
4546
@@ -68,16 +69,31 @@ abstract class ResizeSensorPropsMixin {
6869 ///
6970 /// Default: false
7071 bool shrink;
72+
73+ /// Whether quick-mount mode is enabled, which minimizes layouts caused by accessing element dimensions
74+ /// during initialization, allowing the component to mount faster.
75+ ///
76+ /// When enabled:
77+ ///
78+ /// * The initial dimensions will not be retrieved, so the first [onResize]
79+ /// event will contain `0` for the previous dimensions.
80+ ///
81+ /// * [onInitialize] will never be called.
82+ ///
83+ /// * The sensors will be initialized/reset in the next animation frame after mount, as opposed to synchronously,
84+ /// helping to break up resulting layouts.
85+ ///
86+ /// Default: false
87+ bool quickMount;
7188}
7289
7390@Props ()
7491class ResizeSensorProps extends UiProps with ResizeSensorPropsMixin {}
7592
7693@Component ()
77- class ResizeSensorComponent extends UiComponent <ResizeSensorProps > {
94+ class ResizeSensorComponent extends UiComponent <ResizeSensorProps > with _SafeAnimationFrameMixin {
7895 // Refs
7996
80- Element _expandSensorChildRef;
8197 Element _expandSensorRef;
8298 Element _collapseSensorRef;
8399
@@ -86,139 +102,158 @@ class ResizeSensorComponent extends UiComponent<ResizeSensorProps> {
86102 ..addProps (ResizeSensorPropsMixin .defaultProps)
87103 );
88104
105+ @override
106+ void componentWillUnmount () {
107+ super .componentWillUnmount ();
108+
109+ cancelAnimationFrames ();
110+ }
111+
89112 @override
90113 void componentDidMount () {
91- _reset ();
114+ if (props.quickMount) {
115+ assert (props.onInitialize == null || ValidationUtil .warn (
116+ 'props.onInitialize will not be called when props.quickMount is true.'
117+ ));
118+
119+ // [1] Initialize/reset the sensor in the next animation frame after mount
120+ // so that resulting layouts don't happen synchronously, and are better dispersed.
121+ //
122+ // [2] Ignore the first `2` scroll events triggered by resetting the scroll positions
123+ // of the expand and collapse sensors.
124+ //
125+ // [3] Don't access the dimensions of the sensor to prevent unnecessary layouts.
126+
127+ requestAnimationFrame (() { // [1]
128+ _scrollEventsToIgnore = 2 ; // [2]
129+ _reset (updateLastDimensions: false ); // [3]
130+ });
131+ } else {
132+ _reset ();
92133
93- if (props.onInitialize != null ) {
94- var event = new ResizeSensorEvent (_lastWidth, _lastHeight, 0 , 0 );
95- props.onInitialize (event);
134+ if (props.onInitialize != null ) {
135+ var event = new ResizeSensorEvent (_lastWidth, _lastHeight, 0 , 0 );
136+ props.onInitialize (event);
137+ }
96138 }
97139 }
98140
99141 @override
100142 render () {
101- var expandSensorChild = (Dom .div ()
102- ..ref = (ref) { _expandSensorChildRef = ref; }
103- ..style = _expandSensorChildStyle
104- )();
105-
106143 var expandSensor = (Dom .div ()
107144 ..className = 'resize-sensor-expand'
108145 ..onScroll = _handleSensorScroll
109146 ..style = props.shrink ? _shrinkBaseStyle : _baseStyle
110147 ..ref = (ref) { _expandSensorRef = ref; }
111- ..key = 'expandSensor'
112- )(expandSensorChild);
113-
114- var collapseSensorChild = (Dom .div ()..style = _collapseSensorChildStyle)();
148+ )(
149+ (Dom .div ()..style = _expandSensorChildStyle)()
150+ );
115151
116152 var collapseSensor = (Dom .div ()
117153 ..className = 'resize-sensor-collapse'
118154 ..onScroll = _handleSensorScroll
119155 ..style = props.shrink ? _shrinkBaseStyle : _baseStyle
120156 ..ref = (ref) { _collapseSensorRef = ref; }
121- ..key = 'collapseSensor'
122- )(collapseSensorChild);
123-
124- var children = new List .from (props.children)
125- ..add (
126- (Dom .div ()
127- ..className = 'resize-sensor'
128- ..style = props.shrink ? _shrinkBaseStyle : _baseStyle
129- ..key = 'resizeSensor'
130- )(expandSensor, collapseSensor)
157+ )(
158+ (Dom .div ()..style = _collapseSensorChildStyle)()
131159 );
132160
133- Map <String , dynamic > wrapperStyles;
161+ var resizeSensor = (Dom .div ()
162+ ..className = 'resize-sensor'
163+ ..style = props.shrink ? _shrinkBaseStyle : _baseStyle
164+ ..key = 'resizeSensor'
165+ )(expandSensor, collapseSensor);
134166
167+ Map <String , dynamic > wrapperStyles;
135168 if (props.isFlexChild) {
136- wrapperStyles = {
137- 'position' : 'relative' ,
138- 'flex' : '1 1 0%' ,
139- 'WebkitFlex' : '1 1 0%' ,
140- 'msFlex' : '1 1 0%' ,
141- 'display' : 'block'
142- };
169+ wrapperStyles = _wrapperStylesFlexChild;
143170 } else if (props.isFlexContainer) {
144- wrapperStyles = {
145- 'position' : 'relative' ,
146- 'flex' : '1 1 0%' ,
147- 'WebkitFlex' : '1 1 0%' ,
148- 'msFlex' : '1 1 0%'
149- };
150-
151- // IE 10 and Safari 8 need 'special' value prefixes for 'display:flex'.
152- if (browser.isInternetExplorer && browser.version.major <= 10 ) {
153- wrapperStyles['display' ] = '-ms-flexbox' ;
154- } else if (browser.isSafari && browser.version.major < 9 ) {
155- wrapperStyles['display' ] = '-webkit-flex' ;
156- } else {
157- wrapperStyles['display' ] = 'flex' ;
158- }
159-
171+ wrapperStyles = _wrapperStylesFlexContainer;
160172 } else {
161- wrapperStyles = {
162- 'position' : 'relative' ,
163- 'height' : '100%' ,
164- 'width' : '100%'
165- };
173+ wrapperStyles = _wrapperStyles;;
166174 }
167175
168176 return (Dom .div ()
169177 ..addProps (copyUnconsumedDomProps ())
170178 ..className = forwardingClassNameBuilder ().toClassName ()
171179 ..style = wrapperStyles
172- )(children);
180+ )(
181+ props.children,
182+ resizeSensor
183+ );
173184 }
174185
175186 /// When the expand or collapse sensors are resized, builds a [ResizeSensorEvent] and calls
176187 /// props.onResize with it. Then, calls through to [_reset()] .
177188 void _handleSensorScroll (react.SyntheticEvent _) {
178- Element sensor = findDomNode (this );
189+ if (_scrollEventsToIgnore > 0 ) {
190+ _scrollEventsToIgnore-- ;
191+ return ;
192+ }
179193
180- if (sensor.offsetWidth != _lastWidth || sensor.offsetHeight != _lastHeight) {
181- var event = new ResizeSensorEvent (sensor.offsetWidth, sensor.offsetHeight, _lastWidth, _lastHeight);
194+ var sensor = findDomNode (this );
182195
196+ var newWidth = sensor.offsetWidth;
197+ var newHeight = sensor.offsetHeight;
198+
199+ if (newWidth != _lastWidth || newHeight != _lastHeight) {
183200 if (props.onResize != null ) {
201+ var event = new ResizeSensorEvent (newWidth, newHeight, _lastWidth, _lastHeight);
184202 props.onResize (event);
185203 }
186204
187205 _reset ();
188206 }
189207 }
190208
191- /// Update the width and height on [expandSensorChild] , and the scroll position on
192- /// [expandSensorChild] and [collapseSensor] .
209+ /// Reset the scroll positions on [_expandSensorRef] and [_collapseSensorRef] so that future
210+ /// resizes will trigger scroll events .
193211 ///
194- /// Additionally update the state with the new [_lastWidth] and [_lastHeight] .
195- void _reset () {
196- Element expand = _expandSensorRef;
197- Element expandChild = _expandSensorChildRef;
198- Element collapse = _collapseSensorRef;
199- Element sensor = findDomNode (this );
200-
201- expandChild.style.width = '${expand .offsetWidth + 10 }px' ;
202- expandChild.style.height = '${expand .offsetHeight + 10 }px' ;
203-
204- expand.scrollLeft = expand.scrollWidth;
205- expand.scrollTop = expand.scrollHeight;
212+ /// Additionally update the state with the new [_lastWidth] and [_lastHeight] when [updateLastDimensions] is true.
213+ void _reset ({bool updateLastDimensions: true }) {
214+ if (updateLastDimensions) {
215+ var sensor = findDomNode (this );
216+ _lastWidth = sensor.offsetWidth;
217+ _lastHeight = sensor.offsetHeight;
218+ }
206219
207- collapse.scrollLeft = collapse.scrollWidth;
208- collapse.scrollTop = collapse. scrollHeight;
220+ // Scroll positions are clamped to their maxes; use this behavior to scroll to the end
221+ // as opposed to scrollWidth/ scrollHeight, which trigger reflows immediately.
209222
223+ _expandSensorRef
224+ ..scrollLeft = _maxSensorSize
225+ ..scrollTop = _maxSensorSize;
210226
211- _lastWidth = sensor.offsetWidth;
212- _lastHeight = sensor.offsetHeight;
227+ _collapseSensorRef
228+ ..scrollLeft = _maxSensorSize
229+ ..scrollTop = _maxSensorSize;
213230 }
214231
232+ /// The number of future scroll events to ignore.
233+ ///
234+ /// Resetting the sensors' scroll positions causes sensor scroll events to fire even though a resize didn't occur,
235+ /// so this flag is used to ignore those scroll events on mount for performance reasons in quick-mount mode
236+ /// (since the handler causes a layout by accessing the sensor's dimensions).
237+ ///
238+ /// This value is only set for the component's mount and __not__ reinitialized every time [_reset] is called
239+ /// in order to avoid ignoring scroll events fired by actual resizes at the same time that the reset is taking place.
240+ int _scrollEventsToIgnore = 0 ;
241+
215242 /// The most recently measured value for the height of the sensor.
216243 int _lastHeight = 0 ;
217244
218245 /// The most recently measured value for the width of the sensor.
219246 int _lastWidth = 0 ;
220247}
221248
249+ /// The maximum size, in `px` , the sensor can be: 100,000.
250+ ///
251+ /// We want to use absolute values to avoid accessing element dimensions when possible,
252+ /// and relative units like `%` don't work since they don't cause scroll events when sensor size changes.
253+ ///
254+ /// We could use `rem` or `vh` /`vw` , but that opens us up to more edge cases.
255+ const int _maxSensorSize = 100 * 1000 ;
256+
222257final Map <String , dynamic > _baseStyle = const {
223258 'position' : 'absolute' ,
224259 // Have this element reach "outside" its containing element in such a way to ensure its width/height are always at
@@ -252,6 +287,11 @@ final Map<String, dynamic> _expandSensorChildStyle = const {
252287 'top' : '0' ,
253288 'left' : '0' ,
254289 'visibility' : 'hidden' ,
290+ // Use a width/height that will always be larger than the expandSensor.
291+ // We'd ideally want to do something like calc(100% + 10px), but that doesn't
292+ // trigger scroll events the same way a fixed dimension does.
293+ 'width' : _maxSensorSize,
294+ 'height' : _maxSensorSize,
255295 // Set opacity in addition to visibility to work around Safari scrollbar bug.
256296 'opacity' : '0' ,
257297};
@@ -267,6 +307,33 @@ final Map<String, dynamic> _collapseSensorChildStyle = const {
267307 'opacity' : '0' ,
268308};
269309
310+
311+ const Map <String , dynamic > _wrapperStyles = const {
312+ 'position' : 'relative' ,
313+ 'height' : '100%' ,
314+ 'width' : '100%' ,
315+ };
316+
317+ const Map <String , dynamic > _wrapperStylesFlexChild = const {
318+ 'position' : 'relative' ,
319+ 'flex' : '1 1 0%' ,
320+ 'msFlex' : '1 1 0%' ,
321+ 'display' : 'block' ,
322+ };
323+
324+ final Map <String , dynamic > _wrapperStylesFlexContainer = {
325+ 'position' : 'relative' ,
326+ 'flex' : '1 1 0%' ,
327+ 'msFlex' : '1 1 0%' ,
328+ 'display' : _displayFlex,
329+ };
330+
331+ /// The browser-prefixed value for the CSS `display` property that enables flexbox.
332+ final String _displayFlex = (() {
333+ if (browser.isInternetExplorer && browser.version.major <= 10 ) return '-ms-flexbox' ;
334+ return 'flex' ;
335+ })();
336+
270337/// Used with [ResizeSensorHandler] to provide information about a resize.
271338class ResizeSensorEvent {
272339 /// The new width, in pixels.
@@ -291,3 +358,28 @@ class ResizeSensorPropsMixinMapView extends MapView with ResizeSensorPropsMixin
291358 @override
292359 Map get props => this ;
293360}
361+
362+ /// A mixin that makes it easier to manage animation frames within a React component lifecycle.
363+ class _SafeAnimationFrameMixin {
364+ /// The ids of the pending animation frames.
365+ final _animationFrameIds = < int > [];
366+
367+ /// Calls [Window.requestAnimationFrame] with the specified [callback] , and keeps track of the
368+ /// request ID so that it can be cancelled in [cancelAnimationFrames] .
369+ void requestAnimationFrame (callback ()) {
370+ int queuedId;
371+ queuedId = window.requestAnimationFrame ((_) {
372+ callback ();
373+ _animationFrameIds.remove (queuedId);
374+ });
375+
376+ _animationFrameIds.add (queuedId);
377+ }
378+
379+ /// Cancels all pending animation frames requested by [requestAnimationFrame] .
380+ ///
381+ /// Should be called in [react.Component.componentWillUnmount] .
382+ void cancelAnimationFrames () {
383+ _animationFrameIds.forEach (window.cancelAnimationFrame);
384+ }
385+ }
0 commit comments