Skip to content

Commit

Permalink
Merge pull request #11 from stasgora/scrolling-spaces
Browse files Browse the repository at this point in the history
New scroll widget monitoring approach
  • Loading branch information
stasgora authored May 4, 2021
2 parents 4d406ea + 9e20da6 commit 2d16fc3
Show file tree
Hide file tree
Showing 24 changed files with 923 additions and 214 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ jobs:
run: flutter format --set-exit-if-changed lib
- name: Analyze
run: flutter analyze
- name: Run tests
run: flutter test --no-pub --coverage --test-randomize-ordering-seed random
- name: Upload coverage to Codecov
uses: codecov/[email protected]
15 changes: 12 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
# 0.3.0

- **BREAKING**: reworked `Detector` implementation - placed around the scrolling widgets
- **NEW**: support for lazy loaded scroll widgets like `ListView.builder()`
- **FIX**: events misreported during scrolling
- **NEW**: support for custom scroll widgets - using `Detector(customScrollWidgetAxis: )`
- **REFACTOR**: removed `ListDetector` - not needed anymore
- **FIX**: rendering bug when `Config.heatMapPixelRatio` was changed during session recording

# 0.2.0

- Adjustable heat map resolution
- Unified output callbacks signatures
- **NEW**: Adjustable heat map resolution
- **BREAKING**: Unified output callbacks signatures

# 0.1.1

- Example application
- Private API documentation
- **DOCS**: Private API documentation

# 0.1.0

Expand Down
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,20 +68,20 @@ round_spot.initialize(

#### Route naming
Route names are used to differentiate between pages.
Make sure you are consistently specifying them both when:
- using [named routes](https://flutter.dev/docs/cookbook/navigation/named-routes) and
- pushing a [PageRoute](https://api.flutter.dev/flutter/widgets/PageRoute-class.html) -
using [RouteSetting](https://api.flutter.dev/flutter/widgets/RouteSettings-class.html)
Make sure you are consistently specifying them both when
using [named routes](https://flutter.dev/docs/cookbook/navigation/named-routes) and
pushing [PageRoutes](https://api.flutter.dev/flutter/widgets/PageRoute-class.html)
(inside [RouteSetting](https://api.flutter.dev/flutter/widgets/RouteSettings-class.html))

#### Scrollable widgets
To correctly monitor interactions with any scrollable space a `Detector` or a `ListDetector`
has to be placed between the scrollable widget and the widgets being scrolled:
To correctly monitor interactions with any scrollable space a `Detector`
has to be placed as a direct parent of that widget:
```dart
SingleChildScrollView(
child: round_spot.Detector(
child: /* child */,
areaID: id
)
round_spot.Detector(
areaID: id,
child: ListView(
children: /* children */,
),
)
```

Expand Down
6 changes: 3 additions & 3 deletions example/simple.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ class ExampleApp extends StatelessWidget {
),
),
'second': (context) => Scaffold(
body: SingleChildScrollView(
child: round_spot.ListDetector(
areaID: 'list',
body: round_spot.Detector(
areaID: 'list',
child: ListView(
children: [
for (int i = 0; i < 50; i++) ListTile(title: Text('$i'))
],
Expand Down
9 changes: 9 additions & 0 deletions lib/round_spot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@
/// Scrollable spaces need manual instrumentation to be correctly monitored.
/// Read about the use of a [Detector] widget to learn more.
///
/// ### Current limitations
/// Widgets that dynamically change their size, position, dis/appear
/// or otherwise cause other widgets to shift their positions relative to
/// the viewport (for example in response to a user action) are not supported.
/// When that happens during a session recording it will cause
/// some of the gathered interactions to be displaced in relation
/// to the background image and the rest of data making
/// the heat map difficult to interpret and understand.
///
/// ## Commonly used terms
///
/// ### Area
Expand Down
133 changes: 133 additions & 0 deletions lib/src/components/background_manager.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui';

import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart' hide Image;
import 'package:logging/logging.dart';

import '../models/scrolling_status.dart';
import '../models/session.dart';
import '../utils/utils.dart';

/// Creates backgrounds for sessions
class BackgroundManager {
final _logger = Logger('RoundSpot.BackgroundManager');

/// Controls when to take screenshot depending on the scroll amount
void onScroll(Session session, GlobalKey areaKey) {
if (!session.scrollable) return;
if (session.background == null || _scrollOutsideBounds(session)) {
_takeScreenshot(session, areaKey);
}
}

bool _scrollOutsideBounds(Session session) {
var scroll = session.scrollStatus!;
var background = session.backgroundStatus!;
var diff = background.lastScreenshotPosition - scroll.position;
return (diff).abs() > background.viewportDimension * 0.8;
}

/// Determines if its necessary to take a screenshot when event is recorded
void onEvent(Offset event, Session session, GlobalKey areaKey) {
if (session.background == null || _eventOutsideScreenshot(event, session)) {
_takeScreenshot(session, areaKey);
}
}

bool _eventOutsideScreenshot(Offset event, Session session) {
if (!session.scrollable) return false;
var offset = session.backgroundOffset;
return !(offset & session.background!.size).contains(event);
}

/// Captures a screenshot from a [RepaintBoundary] using its [GlobalKey]
/// It than joins it with the already assembled image
/// replacing the part that's underneath it
void _takeScreenshot(Session session, GlobalKey areaKey) async {
if (!session.scrollable) {
if (session.background != null) return;
session.background = await _captureImage(areaKey, session.pixelRatio);
return;
}
var scroll = session.scrollStatus!;
var background = (session.backgroundStatus ??= BackgroundStatus());
background.lastScreenshotPosition = scroll.position.ceilToDouble();
var queueEmpty = background.screenshotQueue.isEmpty;
background.screenshotQueue.add(Screenshot(
background.lastScreenshotPosition,
_captureImage(areaKey, session.pixelRatio),
));
if (queueEmpty) {
runZonedGuarded(
() => _processScreenshots(session),
(e, stackTrace) => _logger.severe(
'Error occurred while processing screenshot', e, stackTrace),
);
}
}

void _processScreenshots(Session session) async {
var scroll = session.scrollStatus!;
var background = session.backgroundStatus!;
var queue = background.screenshotQueue;
while (queue.isNotEmpty) {
await Future.sync(() async {
var image = await queue.first.image;
if (image == null) return;
var offset = queue.first.offset;
if (session.background == null) {
session.background = image;
background.position = offset;
background.viewportDimension = image.size.alongAxis(scroll.axis);
return;
}
// Decrease the drawn image by 1 pixel in the main axis direction
// to account for the scroll position being rounded to the nearest pixel
var imageSize = image.size.modifiedSize(scroll.axis, -1);
session.background = await image.drawOnto(
session.background!,
Offsets.fromAxis(scroll.axis, offset - background.position),
imageSize,
);
background.position = min(
offset,
background.position,
);
});
queue.removeAt(0);
}
}

Future<Image?> _captureImage(GlobalKey areaKey, double pixelRatio) async {
if (areaKey.currentContext == null) {
_logger.severe('Could not take a screenshot of the current page.');
return null;
}
var screen =
areaKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
return screen.toImage(pixelRatio: pixelRatio);
}
}

/// An extension for drawing one [Image] onto another
extension on Image {
/// Draws this [Image] onto the [base] at [position]
Future<Image> drawOnto(Image base, Offset position, Size size) {
final pictureRecorder = PictureRecorder();
final canvas = Canvas(pictureRecorder);
var origin = Offset.zero;
if (position.dx < 0 || position.dy < 0) {
origin -= position;
position = Offset.zero;
}
canvas.drawImage(base, origin, Paint());
var bounds = position & size;
canvas.drawRect(bounds, Paint()..blendMode = BlendMode.clear);
canvas.drawImageRect(this, Offset.zero & size, bounds, Paint());
var canvasPicture = pictureRecorder.endRecording();
var output = (bounds.size + position) | (base.size + origin);
return canvasPicture.toImage(output.width.toInt(), output.height.toInt());
}
}
22 changes: 14 additions & 8 deletions lib/src/components/heat_map.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class HeatMap {
/// Specifies the cluster [Path] scale in relation to the [pointProximity]
final double clusterScale; // 0.5 - 1.5
/// [Session] being processed
final Session session;
final List<Offset> events;

final _paths = <ClusterPath>[];
final DBSCAN _dbScan;
Expand All @@ -31,11 +31,11 @@ class HeatMap {

/// Creates a [HeatMap] that processes events into paths
HeatMap({
required this.session,
required this.events,
required this.pointProximity,
this.clusterScale = 0.5,
}) : _dbScan = DBSCAN(epsilon: pointProximity) {
var dbPoints = session.events.map<List<double>>((e) => e.locationAsList);
var dbPoints = events.map<List<double>>((e) => [e.dx, e.dy]);
_dbScan.run(dbPoints.toList());
_createClusterPaths();

Expand Down Expand Up @@ -68,19 +68,25 @@ class HeatMap {
for (var cluster in _dbScan.cluster) {
var clusterPath = Path();
for (var pointIndex in cluster) {
var pointPath = session.events[pointIndex].asPath(pointRadius);
var pointPath = _eventPath(events[pointIndex], pointRadius);
clusterPath = Path.combine(PathOperation.union, clusterPath, pointPath);
}
_paths.add(ClusterPath(
path: clusterPath,
points: cluster.map((e) => session.events[e].location).toList()));
path: clusterPath,
points: cluster.map((e) => events[e]).toList(),
));
}
simpleCluster(index) => ClusterPath(
path: session.events[index].asPath(pointRadius),
points: [session.events[index].location]);
path: _eventPath(events[index], pointRadius),
points: [events[index]],
);
_paths.addAll(_dbScan.noise.map(simpleCluster));
}

static double _logBasedLevelScale(double level, double scaleFactor) =>
1 + log(level + 0.5 / scaleFactor) / scaleFactor;

/// Creates a [Path] from this [Event]
Path _eventPath(Offset location, double radius) =>
Path()..addOval(Rect.fromCircle(center: location, radius: radius));
}
49 changes: 43 additions & 6 deletions lib/src/components/processors/graphical_processor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:flutter/painting.dart';
import 'package:logging/logging.dart';

import '../../models/config/heat_map_style.dart';
import '../../models/event.dart';
import '../../models/session.dart';
import '../../utils/utils.dart';
import '../heat_map.dart';
Expand All @@ -17,28 +18,64 @@ class GraphicalProcessor extends SessionProcessor {

@override
Future<Uint8List?> process(Session session) async {
if (session.screenSnap == null) {
if (session.background == null) {
_logger.warning('Got session with no image attached, skipping.');
return null;
}
var image = session.screenSnap!;
var image = session.background!;
final pictureRecorder = PictureRecorder();
final canvas = Canvas(pictureRecorder);
canvas.drawImage(image, Offset.zero, Paint());
var rect = _getClippedImageRect(session);
canvas.drawImageRect(image, rect, Offset.zero & rect.size, Paint());

var alpha = (config.heatMapTransparency * 255).toInt();
canvas.saveLayer(null, Paint()..color = Color.fromARGB(alpha, 0, 0, 0));
_drawHeatMap(canvas, session);
canvas.restore();

var canvasPicture = pictureRecorder.endRecording();
var sessionImage = await canvasPicture.toImage(image.width, image.height);
var sessionImage = await canvasPicture.toImage(
rect.size.width.toInt(),
rect.size.height.toInt(),
);
return exportHeatMap(sessionImage);
}

Rect _getClippedImageRect(Session session) {
var offset = Offset.zero;
var image = session.background!;
var size = image.size;
if (session.scrollable) {
var scroll = session.scrollStatus!;
var background = session.backgroundStatus!;
getPosition(Event e) => e.location.alongAxis(scroll.axis);
var eventPositions = session.events.map(getPosition);
final cutMargin = background.viewportDimension;
var extent = Offset(
max(scroll.extent.dx, eventPositions.reduce(min) - cutMargin),
min(
scroll.extent.dy + background.viewportDimension,
eventPositions.reduce(max) + cutMargin,
),
);
var diff = background.position - extent.dx;
if (diff < 0) {
offset = Offsets.fromAxis(scroll.axis, -diff);
size = size.modifiedSize(scroll.axis, diff);
background.position = extent.dx;
}
diff = extent.dy - background.position - size.alongAxis(scroll.axis);
if (diff < 0) size = size.modifiedSize(scroll.axis, diff);
}
return offset & size;
}

void _drawHeatMap(Canvas canvas, Session session) {
var clusterScale = config.uiElementSize * config.heatMapPixelRatio;
var heatMap = HeatMap(session: session, pointProximity: clusterScale);
var clusterScale = config.uiElementSize * session.pixelRatio;
var events = session.events
.map((e) => e.location - session.backgroundOffset)
.toList();
var heatMap = HeatMap(events: events, pointProximity: clusterScale);

layerCount() {
if (heatMap.largestCluster == 1) return 1;
Expand Down
3 changes: 2 additions & 1 deletion lib/src/components/processors/session_processor.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:typed_data';

import 'package:flutter/foundation.dart';
import 'package:meta/meta.dart';

import '../../models/config/config.dart';
import '../../models/session.dart';
import '../../utils/components.dart';
Expand Down
21 changes: 0 additions & 21 deletions lib/src/components/screenshot_provider.dart

This file was deleted.

Loading

0 comments on commit 2d16fc3

Please sign in to comment.