Multidirectional scroll in flutter

Viewed 5473

I come from a web development background, and am used to being able to create an element that has both x and y overflow (allowing scrolling in any direction). I'm struggling to achieve the same functionality with Flutter.

Looking through the documentation, I've found SingleChildScrollView, but it only allows Axis.horizontal or Axis.vertical, not both.

So I have tried the following:

return SingleChildScrollView( // horizontal scroll widget
    scrollDirection: Axis.horizontal,
        child: SingleChildScrollView( // vertical scroll widget
            scrollDirection: Axis.vertical,
            child: ...content of the container etc...
        )
    );

This works for both x and y, but it doesn't allow for diagonal scrolling.

Is there a way to achieve diagonal scrolling, or is there a better material widget that I'm completely missing?

Thanks

2 Answers

I've managed to find a solution, though it's not perfect:

I've created a StatefulWidget with an Offset _scrollOffset that uses a ClipRect with a child of type Transform. A transformation matrix (Matrix4.identity()..translate(_offset.dx, _offset.dy)) is applied to the transform.

A GestureDetector has an onPanUpdate callback assigned to update the scroll position. _scrollOffset += e.delta. This can be constrained to the boundaries of the widget by just setting the scroll position if it's too low or too high.

An Animation and AnimationController are used to set up fling velocity. onPanEnd provides the velocity of the last pan, so just performs a Tween with a fling based on that velocity.

The animation is stopped onTapDown so the user can stop the scroll velocity.

The main problem with this is it doesn't perfectly mimmick the Android or iOS scroll velocity, though I am working on trying to get it to work better using Flutter's provided ScrollSimulation classes.

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

class FreeScrollView extends StatefulWidget {
  final Widget child;
  final ScrollPhysics physics;

  const FreeScrollView({Key? key, this.physics = const ClampingScrollPhysics(), required this.child}) : super(key: key);

  @override
  State<FreeScrollView> createState() => _FreeScrollViewState();
}

class _FreeScrollViewState extends State<FreeScrollView> {
  final ScrollController _verticalController = ScrollController();
  final ScrollController _horizontalController = ScrollController();
  final Map<Type, GestureRecognizerFactory> _gestureRecognizers = <Type, GestureRecognizerFactory>{};

  @override
  void initState() {
    super.initState();
    _gestureRecognizers[PanGestureRecognizer] = GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
        () => PanGestureRecognizer(),
        (instance) => instance
          ..onDown = _handleDragDown
          ..onStart = _handleDragStart
          ..onUpdate = _handleDragUpdate
          ..onEnd = _handleDragEnd
          ..onCancel = _handleDragCancel
          ..minFlingDistance = widget.physics.minFlingDistance
          ..minFlingVelocity = widget.physics.minFlingVelocity
          ..maxFlingVelocity = widget.physics.maxFlingVelocity
          ..velocityTrackerBuilder = ScrollConfiguration.of(context).velocityTrackerBuilder(context)
          ..dragStartBehavior = DragStartBehavior.start);
  }

  @override
  Widget build(BuildContext context) => Stack(children: [
        SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            controller: _horizontalController,
            physics: widget.physics,
            child: SingleChildScrollView(
                scrollDirection: Axis.vertical, // ignore: avoid_redundant_argument_values
                controller: _verticalController,
                physics: widget.physics,
                child: widget.child)),
        Positioned.fill(
            child: RawGestureDetector(
          gestures: _gestureRecognizers,
          behavior: HitTestBehavior.opaque,
          excludeFromSemantics: true,
        )),
      ]);

  Drag? _horizontalDrag;
  Drag? _verticalDrag;
  ScrollHoldController? _horizontalHold;
  ScrollHoldController? _verticalHold;

  void _handleDragDown(DragDownDetails details) {
    _horizontalHold = _horizontalController.position.hold(() => _horizontalHold = null);
    _verticalHold = _verticalController.position.hold(() => _verticalHold = null);
  }

  void _handleDragStart(DragStartDetails details) {
    _horizontalDrag = _horizontalController.position.drag(details, () => _horizontalDrag = null);
    _verticalDrag = _verticalController.position.drag(details, () => _verticalDrag = null);
  }

  void _handleDragUpdate(DragUpdateDetails details) {
    _horizontalDrag?.update(DragUpdateDetails(
        sourceTimeStamp: details.sourceTimeStamp,
        delta: Offset(details.delta.dx, 0),
        primaryDelta: details.delta.dx,
        globalPosition: details.globalPosition));
    _verticalDrag?.update(DragUpdateDetails(
        sourceTimeStamp: details.sourceTimeStamp,
        delta: Offset(0, details.delta.dy),
        primaryDelta: details.delta.dy,
        globalPosition: details.globalPosition));
  }

  void _handleDragEnd(DragEndDetails details) {
    _horizontalDrag
        ?.end(DragEndDetails(velocity: details.velocity, primaryVelocity: details.velocity.pixelsPerSecond.dx));
    _verticalDrag
        ?.end(DragEndDetails(velocity: details.velocity, primaryVelocity: details.velocity.pixelsPerSecond.dy));
  }

  void _handleDragCancel() {
    _horizontalHold?.cancel();
    _horizontalDrag?.cancel();
    _verticalHold?.cancel();
    _verticalDrag?.cancel();
  }
}
Related