Skip to content

Commit

Permalink
Merge pull request #46 from greglittlefield-wf/resize_sensor_quick_mount
Browse files Browse the repository at this point in the history
UIP-1976, UIP-1975 Release over_react 1.5.0 (HOTFIX)
  • Loading branch information
leviwith-wf authored Jan 30, 2017
2 parents bb39096 + d3e9ea7 commit 6329e2a
Show file tree
Hide file tree
Showing 4 changed files with 440 additions and 202 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# OverReact Changelog

## 1.5.0
* Add `ResizeSensorProps.quickMount` flag for better performance when sensors are mounted often #46
* Add missing quiver dependency (now depends on quiver `>=0.21.4 <0.25.0`)
* Broaden analyzer dependency range to `>=0.26.1+3 <0.30.0` (was `>=0.26.1+3 <0.28.0`)

## 1.4.0

> [Complete `1.4.0` Changeset](https://github.com/Workiva/over_react/compare/1.3.0...1.4.0)
Expand Down
246 changes: 169 additions & 77 deletions lib/src/component/resize_sensor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ abstract class ResizeSensorPropsMixin {
static final ResizeSensorPropsMixinMapView defaultProps = new ResizeSensorPropsMixinMapView({})
..isFlexChild = false
..isFlexContainer = false
..shrink = false;
..shrink = false
..quickMount = false;

Map get props;

Expand Down Expand Up @@ -68,16 +69,31 @@ abstract class ResizeSensorPropsMixin {
///
/// Default: false
bool shrink;

/// Whether quick-mount mode is enabled, which minimizes layouts caused by accessing element dimensions
/// during initialization, allowing the component to mount faster.
///
/// When enabled:
///
/// * The initial dimensions will not be retrieved, so the first [onResize]
/// event will contain `0` for the previous dimensions.
///
/// * [onInitialize] will never be called.
///
/// * The sensors will be initialized/reset in the next animation frame after mount, as opposed to synchronously,
/// helping to break up resulting layouts.
///
/// Default: false
bool quickMount;
}

@Props()
class ResizeSensorProps extends UiProps with ResizeSensorPropsMixin {}

@Component()
class ResizeSensorComponent extends UiComponent<ResizeSensorProps> {
class ResizeSensorComponent extends UiComponent<ResizeSensorProps> with _SafeAnimationFrameMixin {
// Refs

Element _expandSensorChildRef;
Element _expandSensorRef;
Element _collapseSensorRef;

Expand All @@ -86,139 +102,158 @@ class ResizeSensorComponent extends UiComponent<ResizeSensorProps> {
..addProps(ResizeSensorPropsMixin.defaultProps)
);

@override
void componentWillUnmount() {
super.componentWillUnmount();

cancelAnimationFrames();
}

@override
void componentDidMount() {
_reset();
if (props.quickMount) {
assert(props.onInitialize == null || ValidationUtil.warn(
'props.onInitialize will not be called when props.quickMount is true.'
));

// [1] Initialize/reset the sensor in the next animation frame after mount
// so that resulting layouts don't happen synchronously, and are better dispersed.
//
// [2] Ignore the first `2` scroll events triggered by resetting the scroll positions
// of the expand and collapse sensors.
//
// [3] Don't access the dimensions of the sensor to prevent unnecessary layouts.

requestAnimationFrame(() { // [1]
_scrollEventsToIgnore = 2; // [2]
_reset(updateLastDimensions: false); // [3]
});
} else {
_reset();

if (props.onInitialize != null) {
var event = new ResizeSensorEvent(_lastWidth, _lastHeight, 0, 0);
props.onInitialize(event);
if (props.onInitialize != null) {
var event = new ResizeSensorEvent(_lastWidth, _lastHeight, 0, 0);
props.onInitialize(event);
}
}
}

@override
render() {
var expandSensorChild = (Dom.div()
..ref = (ref) { _expandSensorChildRef = ref; }
..style = _expandSensorChildStyle
)();

var expandSensor = (Dom.div()
..className = 'resize-sensor-expand'
..onScroll = _handleSensorScroll
..style = props.shrink ? _shrinkBaseStyle : _baseStyle
..ref = (ref) { _expandSensorRef = ref; }
..key = 'expandSensor'
)(expandSensorChild);

var collapseSensorChild = (Dom.div()..style = _collapseSensorChildStyle)();
)(
(Dom.div()..style = _expandSensorChildStyle)()
);

var collapseSensor = (Dom.div()
..className = 'resize-sensor-collapse'
..onScroll = _handleSensorScroll
..style = props.shrink ? _shrinkBaseStyle : _baseStyle
..ref = (ref) { _collapseSensorRef = ref; }
..key = 'collapseSensor'
)(collapseSensorChild);

var children = new List.from(props.children)
..add(
(Dom.div()
..className = 'resize-sensor'
..style = props.shrink ? _shrinkBaseStyle : _baseStyle
..key = 'resizeSensor'
)(expandSensor, collapseSensor)
)(
(Dom.div()..style = _collapseSensorChildStyle)()
);

Map<String, dynamic> wrapperStyles;
var resizeSensor = (Dom.div()
..className = 'resize-sensor'
..style = props.shrink ? _shrinkBaseStyle : _baseStyle
..key = 'resizeSensor'
)(expandSensor, collapseSensor);

Map<String, dynamic> wrapperStyles;
if (props.isFlexChild) {
wrapperStyles = {
'position': 'relative',
'flex': '1 1 0%',
'WebkitFlex': '1 1 0%',
'msFlex': '1 1 0%',
'display': 'block'
};
wrapperStyles = _wrapperStylesFlexChild;
} else if (props.isFlexContainer) {
wrapperStyles = {
'position': 'relative',
'flex': '1 1 0%',
'WebkitFlex': '1 1 0%',
'msFlex': '1 1 0%'
};

// IE 10 and Safari 8 need 'special' value prefixes for 'display:flex'.
if (browser.isInternetExplorer && browser.version.major <= 10) {
wrapperStyles['display'] = '-ms-flexbox';
} else if (browser.isSafari && browser.version.major < 9) {
wrapperStyles['display'] = '-webkit-flex';
} else {
wrapperStyles['display'] = 'flex';
}

wrapperStyles = _wrapperStylesFlexContainer;
} else {
wrapperStyles = {
'position': 'relative',
'height': '100%',
'width': '100%'
};
wrapperStyles = _wrapperStyles;;
}

return (Dom.div()
..addProps(copyUnconsumedDomProps())
..className = forwardingClassNameBuilder().toClassName()
..style = wrapperStyles
)(children);
)(
props.children,
resizeSensor
);
}

/// When the expand or collapse sensors are resized, builds a [ResizeSensorEvent] and calls
/// props.onResize with it. Then, calls through to [_reset()].
void _handleSensorScroll(react.SyntheticEvent _) {
Element sensor = findDomNode(this);
if (_scrollEventsToIgnore > 0) {
_scrollEventsToIgnore--;
return;
}

if (sensor.offsetWidth != _lastWidth || sensor.offsetHeight != _lastHeight) {
var event = new ResizeSensorEvent(sensor.offsetWidth, sensor.offsetHeight, _lastWidth, _lastHeight);
var sensor = findDomNode(this);

var newWidth = sensor.offsetWidth;
var newHeight = sensor.offsetHeight;

if (newWidth != _lastWidth || newHeight != _lastHeight) {
if (props.onResize != null) {
var event = new ResizeSensorEvent(newWidth, newHeight, _lastWidth, _lastHeight);
props.onResize(event);
}

_reset();
}
}

/// Update the width and height on [expandSensorChild], and the scroll position on
/// [expandSensorChild] and [collapseSensor].
/// Reset the scroll positions on [_expandSensorRef] and [_collapseSensorRef] so that future
/// resizes will trigger scroll events.
///
/// Additionally update the state with the new [_lastWidth] and [_lastHeight].
void _reset() {
Element expand = _expandSensorRef;
Element expandChild = _expandSensorChildRef;
Element collapse = _collapseSensorRef;
Element sensor = findDomNode(this);

expandChild.style.width = '${expand.offsetWidth + 10}px';
expandChild.style.height = '${expand.offsetHeight + 10}px';

expand.scrollLeft = expand.scrollWidth;
expand.scrollTop = expand.scrollHeight;
/// Additionally update the state with the new [_lastWidth] and [_lastHeight] when [updateLastDimensions] is true.
void _reset({bool updateLastDimensions: true}) {
if (updateLastDimensions) {
var sensor = findDomNode(this);
_lastWidth = sensor.offsetWidth;
_lastHeight = sensor.offsetHeight;
}

collapse.scrollLeft = collapse.scrollWidth;
collapse.scrollTop = collapse.scrollHeight;
// Scroll positions are clamped to their maxes; use this behavior to scroll to the end
// as opposed to scrollWidth/scrollHeight, which trigger reflows immediately.

_expandSensorRef
..scrollLeft = _maxSensorSize
..scrollTop = _maxSensorSize;

_lastWidth = sensor.offsetWidth;
_lastHeight = sensor.offsetHeight;
_collapseSensorRef
..scrollLeft = _maxSensorSize
..scrollTop = _maxSensorSize;
}

/// The number of future scroll events to ignore.
///
/// Resetting the sensors' scroll positions causes sensor scroll events to fire even though a resize didn't occur,
/// so this flag is used to ignore those scroll events on mount for performance reasons in quick-mount mode
/// (since the handler causes a layout by accessing the sensor's dimensions).
///
/// This value is only set for the component's mount and __not__ reinitialized every time [_reset] is called
/// in order to avoid ignoring scroll events fired by actual resizes at the same time that the reset is taking place.
int _scrollEventsToIgnore = 0;

/// The most recently measured value for the height of the sensor.
int _lastHeight = 0;

/// The most recently measured value for the width of the sensor.
int _lastWidth = 0;
}

/// The maximum size, in `px`, the sensor can be: 100,000.
///
/// We want to use absolute values to avoid accessing element dimensions when possible,
/// and relative units like `%` don't work since they don't cause scroll events when sensor size changes.
///
/// We could use `rem` or `vh`/`vw`, but that opens us up to more edge cases.
const int _maxSensorSize = 100 * 1000;

final Map<String, dynamic> _baseStyle = const {
'position': 'absolute',
// Have this element reach "outside" its containing element in such a way to ensure its width/height are always at
Expand Down Expand Up @@ -252,6 +287,11 @@ final Map<String, dynamic> _expandSensorChildStyle = const {
'top': '0',
'left': '0',
'visibility': 'hidden',
// Use a width/height that will always be larger than the expandSensor.
// We'd ideally want to do something like calc(100% + 10px), but that doesn't
// trigger scroll events the same way a fixed dimension does.
'width': _maxSensorSize,
'height': _maxSensorSize,
// Set opacity in addition to visibility to work around Safari scrollbar bug.
'opacity': '0',
};
Expand All @@ -267,6 +307,33 @@ final Map<String, dynamic> _collapseSensorChildStyle = const {
'opacity': '0',
};


const Map<String, dynamic> _wrapperStyles = const {
'position': 'relative',
'height': '100%',
'width': '100%',
};

const Map<String, dynamic> _wrapperStylesFlexChild = const {
'position': 'relative',
'flex': '1 1 0%',
'msFlex': '1 1 0%',
'display': 'block',
};

final Map<String, dynamic> _wrapperStylesFlexContainer = {
'position': 'relative',
'flex': '1 1 0%',
'msFlex': '1 1 0%',
'display': _displayFlex,
};

/// The browser-prefixed value for the CSS `display` property that enables flexbox.
final String _displayFlex = (() {
if (browser.isInternetExplorer && browser.version.major <= 10) return '-ms-flexbox';
return 'flex';
})();

/// Used with [ResizeSensorHandler] to provide information about a resize.
class ResizeSensorEvent {
/// The new width, in pixels.
Expand All @@ -291,3 +358,28 @@ class ResizeSensorPropsMixinMapView extends MapView with ResizeSensorPropsMixin
@override
Map get props => this;
}

/// A mixin that makes it easier to manage animation frames within a React component lifecycle.
class _SafeAnimationFrameMixin {
/// The ids of the pending animation frames.
final _animationFrameIds = <int>[];

/// Calls [Window.requestAnimationFrame] with the specified [callback], and keeps track of the
/// request ID so that it can be cancelled in [cancelAnimationFrames].
void requestAnimationFrame(callback()) {
int queuedId;
queuedId = window.requestAnimationFrame((_) {
callback();
_animationFrameIds.remove(queuedId);
});

_animationFrameIds.add(queuedId);
}

/// Cancels all pending animation frames requested by [requestAnimationFrame].
///
/// Should be called in [react.Component.componentWillUnmount].
void cancelAnimationFrames() {
_animationFrameIds.forEach(window.cancelAnimationFrame);
}
}
6 changes: 3 additions & 3 deletions pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
name: over_react
version: 1.4.0
version: 1.5.0
description: A library for building statically-typed React UI components using Dart.
homepage: https://github.com/Workiva/over_react/
authors:
- Workiva UI Platform Team <uip@workiva.com>
environment:
sdk: ">=1.19.1"
dependencies:
analyzer: ">=0.26.1+3 <0.28.0"
analyzer: ">=0.26.1+3 <0.30.0"
barback: "^0.15.0"
react: "^3.1.0"
source_span: "^1.2.0"
transformer_utils: "^0.1.1"
w_flux: "^2.5.0"
platform_detect: "^1.1.1"
quiver: ">=0.21.4 <0.25.0"
dev_dependencies:
matcher: ">=0.11.0 <0.13.0"
coverage: "^0.7.2"
dart_dev: "^1.0.5"
markdown: "^0.8.0"
mockito: "^0.11.0"
test: "^0.12.6+2"

Expand Down
Loading

0 comments on commit 6329e2a

Please sign in to comment.