Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interactive scrollbar for vertical scroll on Desktop #64

Open
patricknicolosi opened this issue Mar 5, 2021 · 1 comment
Open

Interactive scrollbar for vertical scroll on Desktop #64

patricknicolosi opened this issue Mar 5, 2021 · 1 comment
Assignees
Labels
T: Feature Type: :tada: New Features

Comments

@patricknicolosi
Copy link

patricknicolosi commented Mar 5, 2021

With the new version of Flutter 2, stable web apps are created. One of the most interesting things is the interactive scrollbar that can be dragged with the mouse. The idea is to add smart mouse scrolling via Scrollbar for desktop devices and use the default package scrolling for mobile devices.

A simple solution would be to recognize a desktop by screen size.

A more complex solution could be to recognize a desktop from the pointing device via MouseRegion ()

This is a possible implementation via MediaQuery() in vertical_zoom.dart:


import 'package:dartx/dartx.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import '../theme.dart';

@immutable
abstract class InitialZoom {
  const InitialZoom();

  const factory InitialZoom.zoom(double zoom) = _FactorInitialZoom;
  const factory InitialZoom.range({
    double startFraction,
    double endFraction,
  }) = _RangeInitialZoom;

  double getContentHeight(double parentHeight);
  double getOffset(double parentHeight, double contentHeight);
}

class _FactorInitialZoom extends InitialZoom {
  const _FactorInitialZoom(this.zoom)
      : assert(zoom != null),
        assert(zoom > 0);

  final double zoom;

  @override
  double getContentHeight(double parentHeight) => parentHeight * zoom;
  @override
  double getOffset(double parentHeight, double contentHeight) {
    // Center the viewport vertically.
    return (contentHeight - parentHeight) / 2;
  }
}

class _RangeInitialZoom extends InitialZoom {
  const _RangeInitialZoom({
    this.startFraction = 0,
    this.endFraction = 1,
  })  : assert(startFraction != null),
        assert(0 <= startFraction),
        assert(endFraction != null),
        assert(endFraction <= 1),
        assert(startFraction < endFraction);

  final double startFraction;
  final double endFraction;

  @override
  double getContentHeight(double parentHeight) =>
      parentHeight / (endFraction - startFraction);

  @override
  double getOffset(double parentHeight, double contentHeight) =>
      contentHeight * startFraction;
}

class VerticalZoom extends StatefulWidget {
  const VerticalZoom({
    Key key,
    this.initialZoom = const InitialZoom.zoom(1),
    @required this.child,
    this.minChildHeight = 1,
    this.maxChildHeight = double.infinity,
  })  : assert(initialZoom != null),
        assert(child != null),
        assert(minChildHeight != null),
        assert(minChildHeight > 0),
        assert(maxChildHeight != null),
        assert(maxChildHeight > 0),
        assert(minChildHeight <= maxChildHeight),
        super(key: key);

  final InitialZoom initialZoom;

  final Widget child;
  final double minChildHeight;
  final double maxChildHeight;

  @override
  _VerticalZoomState createState() => _VerticalZoomState();
}

class _VerticalZoomState extends State<VerticalZoom> {
  ScrollController _scrollController;
  // We store height i/o zoom factor so our child stays constant when we change
  // height.
  double _contentHeight;
  double _contentHeightUpdateReference;
  double _lastFocus;

  @override
  void initState() {
    super.initState();
  }

  @override
  void dispose() {
    _scrollController?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final timetableTheme = context.timetableTheme;

    return LayoutBuilder(
      builder: (context, constraints) {
        final height = constraints.maxHeight;

        _contentHeight ??= _coerceContentHeight(
          widget.initialZoom.getContentHeight(height),
          height,
          timetableTheme,
        );
        _scrollController ??= ScrollController(
          initialScrollOffset:
              widget.initialZoom.getOffset(height, _contentHeight),
        );

        return MediaQuery.of(context).size.width > 950
            ? Scrollbar(
                controller: _scrollController,
                isAlwaysShown: true,
                child: SingleChildScrollView(
                  controller: _scrollController,
                  // We handle scrolling manually to improve zoom detection.
                  child: SizedBox(
                    height: _contentHeight,
                    child: widget.child,
                  ),
                ),
              )
            : GestureDetector(
                dragStartBehavior: DragStartBehavior.down,
                onScaleStart: (details) => _onZoomStart(height, details),
                onScaleUpdate: (details) =>
                    _onZoomUpdate(height, details, timetableTheme),
                child: SingleChildScrollView(
                  // We handle scrolling manually to improve zoom detection.
                  physics: NeverScrollableScrollPhysics(),
                  controller: _scrollController,
                  child: SizedBox(
                    height: _contentHeight,
                    child: widget.child,
                  ),
                ),
              );
      },
    );
  }

  void _onZoomStart(double height, ScaleStartDetails details) {
    _contentHeightUpdateReference = _contentHeight;
    _lastFocus = _getFocus(height, details.localFocalPoint);
  }

  void _onZoomUpdate(
    double height,
    ScaleUpdateDetails details,
    TimetableThemeData theme,
  ) {
    setState(() {
      _contentHeight = _coerceContentHeight(
        details.verticalScale * _contentHeightUpdateReference,
        height,
        theme,
      );

      final scrollOffset =
          _lastFocus * _contentHeight - details.localFocalPoint.dy;
      _scrollController.jumpTo(
          scrollOffset.coerceIn(0, (_contentHeight - height).coerceAtLeast(0)));

      _lastFocus = _getFocus(height, details.localFocalPoint);
    });
  }

  double _coerceContentHeight(
    double childHeight,
    double parentHeight,
    TimetableThemeData theme,
  ) {
    return childHeight
        .coerceIn(widget.minChildHeight, widget.maxChildHeight)
        .coerceIn(
          (theme?.minimumHourZoom ?? 0) * parentHeight,
          (theme?.maximumHourZoom ?? double.infinity) * parentHeight,
        );
  }

  double _getFocus(double height, Offset focalPoint) =>
      (_scrollController.offset + focalPoint.dy) / _contentHeight;
}


The result:

2021-03-05 10-13-43_Trim (1)

@patricknicolosi patricknicolosi added the T: Feature Type: :tada: New Features label Mar 5, 2021
@JonasWanke
Copy link
Owner

Thanks for the detailed suggestion and sample code, I'll try to incorporate that into the rewrite I'm currently working on (#17)!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
T: Feature Type: :tada: New Features
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants