简体   繁体   中英

Flutter: How to create a custom scrollable widget

I'm trying to implement a horizontal scrollable value selector, similar to this one: 可滚动的磁带值选择器

The user scrolls the "tape" left or right to select the value (displayed in the middle box). The tape has max and min values, which upon being reached will show the typical overscroll animation (glow on Android; bounce on iOS).

Hixie suggested on Gitter that I could just use a GestureDetector + CustomPaint , but I have a feeling that I would have to implement the scrolling logic myself and wouldn't take advantage of Flutter's fling and overscroll implementations.

EDIT: After further investigation I changed my original approach which was using low-level widgets such as Scrollable and Viewport .

I have been able to create the tape by extending CustomPaint and setting its width to the full length of the tape: _width = (_maxValue - _minValue) * _spacing;

Then I put my custom widget inside a CustomScrollView:

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

void main() {
  runApp(new MaterialApp(home: new Scaffold(
      appBar: new AppBar(title: new Text("Test"),),
      body: new CustomScrollView(
        scrollDirection: Axis.horizontal,
        slivers: <Widget>[
          new SliverToBoxAdapter(
            child: new Tape(),
          )
        ],
      )
  )));
}

const _width = (_maxValue - _minValue) * spacing;
const spacing = 20.0;
const _minValue = 0;
const _maxValue = 100;

class Tape extends CustomPaint {
  Tape() : super(
    size: new Size(_width, 60.0),
    painter: new _TapePainter(),
  );
}

class _TapePainter extends CustomPainter {
  Paint _tickPaint;

  _TapePainter() {
    _tickPaint = new Paint();
    _tickPaint.color = Colors.black;
    _tickPaint.strokeWidth = 1.0;
  }

  @override
  void paint(Canvas canvas, Size size) {
    var rect = Offset.zero & size;

    var o1 = new Offset(0.0, 0.0);
    var o2 = new Offset(0.0, rect.height);

    while (o1.dx < size.width) {
      canvas.drawLine(o1, o2, _tickPaint);
      o1 = o1.translate(spacing, 0.0);
      o2 = o2.translate(spacing, 0.0);
    }
  }

  @override
  bool shouldRepaint(_TapePainter oldDelegate) {
    return true;
  }
}

This achieves the effect I want: I'm now able to scroll the tape left and right, and get the overscroll effects for free.

The problem is that the current code is inefficient: the entire tape is drawn once and the scroller simply moves through the buffered bitmap. This causes problems for very large "tapes".

Instead, what I'm looking for, is repainting the widget on every frame so that only the visible part needs to be calculated and drawn. This would also allow me to implement other scroll-dependent effects, eg dynamically fading numbers in as they approach the centre.

So after quite a bit of investigating, I managed to solve this. I'm pretty sure my solution isn't the best way to do this, but it works. I'd be grateful if anyone could comment on the quality of the solution and on how it can be improved.

I copied the code from SliverBoxAdapter to return a custom version of RenderSliverToBoxAdapter which exposes the visible geometry (the portion of the widget that is actually visible) at each layout pass. My CustomPainter then uses this information to limit the drawing commands to only those which appear within the visible region.

Please be aware that the code below is intended as a proof of concept and therefore ugly. I will be extending it to a full-fledged solution here: https://github.com/cachapa/FlutterTapeSelector

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

void main() {
  runApp(new MaterialApp(
      home: new Scaffold(
          appBar: new AppBar(
            title: new Text("Test"),
          ),
          body: new CustomScrollView(
            scrollDirection: Axis.horizontal,
            slivers: <Widget>[
              new CustomSliverToBoxAdapter(
                child: new Tape(),
              )
            ],
          ))));
}

const _width = (_maxValue - _minValue) * spacing;
const spacing = 20.0;
const _minValue = 0;
const _maxValue = 100;

class Tape extends CustomPaint {
  Tape()
      : super(
          size: new Size(_width, 60.0),
          painter: new _TapePainter(),
        );
}

class _TapePainter extends CustomPainter {
  Paint _tickPaint = new Paint();

  _TapePainter() {
    _tickPaint.color = Colors.black;
    _tickPaint.strokeWidth = 2.0;
  }

  @override
  void paint(Canvas canvas, Size size) {
    var rect = Offset.zero & size;

    // Extend drawing window to compensate for element sizes - avoids lines at either end "popping" into existence
    var extend = _tickPaint.strokeWidth / 2.0;

    // Calculate from which Tick we should start drawing
    var tick = ((_visibleRect.left - extend) / spacing).ceil();

    var startOffset = tick * spacing;
    var o1 = new Offset(startOffset, 0.0);
    var o2 = new Offset(startOffset, rect.height);

    while (o1.dx < _visibleRect.right + extend) {
      canvas.drawLine(o1, o2, _tickPaint);
      o1 = o1.translate(spacing, 0.0);
      o2 = o2.translate(spacing, 0.0);
    }
  }

  @override
  bool shouldRepaint(_TapePainter oldDelegate) {
    return false;
  }
}

class CustomSliverToBoxAdapter extends SingleChildRenderObjectWidget {
  const CustomSliverToBoxAdapter({
    Key key,
    Widget child,
  })
      : super(key: key, child: child);

  @override
  CustomRenderSliverToBoxAdapter createRenderObject(BuildContext context) =>
      new CustomRenderSliverToBoxAdapter();
}

class CustomRenderSliverToBoxAdapter extends RenderSliverSingleBoxAdapter {
  CustomRenderSliverToBoxAdapter({
    RenderBox child,
  })
      : super(child: child);

  @override
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    child.layout(constraints.asBoxConstraints(), parentUsesSize: true);
    double childExtent;
    switch (constraints.axis) {
      case Axis.horizontal:
        childExtent = child.size.width;
        break;
      case Axis.vertical:
        childExtent = child.size.height;
        break;
    }
    assert(childExtent != null);
    final double paintedChildSize =
        calculatePaintOffset(constraints, from: 0.0, to: childExtent);
    assert(paintedChildSize.isFinite);
    assert(paintedChildSize >= 0.0);
    geometry = new SliverGeometry(
      scrollExtent: childExtent,
      paintExtent: paintedChildSize,
      maxPaintExtent: childExtent,
      hitTestExtent: paintedChildSize,
      hasVisualOverflow: childExtent > constraints.remainingPaintExtent ||
          constraints.scrollOffset > 0.0,
    );
    setChildParentData(child, constraints, geometry);

    // Expose geometry
    _visibleRect = new Rect.fromLTWH(
        constraints.scrollOffset, 0.0, geometry.paintExtent, child.size.height);
  }
}

Rect _visibleRect = Rect.zero;

To be honest I don't really understand your question, but recently I was developing NumberPicker. Maybe it will help you somehow. https://github.com/MarcinusX/NumberPicker

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM