首页 > 解决方案 > 如何在颤动中缩放ListView内的图像

问题描述

我正在编写一个 Flutter 应用程序,我想知道如何在 ListView 中使用/实现可缩放图像。我在我的应用程序中使用了以下插件。

他们都没有参与我的项目并抛出了不同的异常。重现错误的示例代码:

flutter_advanced_networkimage:

import 'package:flutter/material.dart';
import 'package:flutter_advanced_networkimage/flutter_advanced_networkimage.dart';
import 'package:flutter_advanced_networkimage/transition_to_image.dart';
import 'package:flutter_advanced_networkimage/zoomable_widget.dart';

void main() {
  runApp(new ZoomableImageInListView());
}

class ZoomableImageInListView extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return new _ZoomableImageInListViewState();
  }
}

final List<String> _urlList = [
  'https://www.w3schools.com/htmL/pic_trulli.jpg',
  'https://www.w3schools.com/htmL/img_girl.jpg',
  'https://www.w3schools.com/htmL/img_chania.jpg',
];

class _ZoomableImageInListViewState extends State<ZoomableImageInListView> {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Zoomable Image In ListView',
      debugShowCheckedModeBanner: false,
      home: new Scaffold(
        body: new Column(
          children: <Widget>[
            new Expanded(
              child: new ListView.builder(
                scrollDirection: Axis.vertical,
                itemBuilder: _buildVerticalChild,
              ),
            ),
          ],
        ),
      ),
    );
  }

  _buildVerticalChild(BuildContext context, int index) {
    index++;
    if (index > _urlList.length) return null;
    TransitionToImage imageWidget = TransitionToImage(
      AdvancedNetworkImage(
        _urlList[index],
        useDiskCache: true,
      ),
      useReload: true,
      reloadWidget: Icon(Icons.replay),
    );
    return new ZoomableWidget(
      minScale: 1.0,
      maxScale: 5.0,
      child: imageWidget,
      tapCallback: imageWidget.reloadImage,
    );
  }
}

抛出了这个异常:

I/flutter (13594): ══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
I/flutter (13594): The following assertion was thrown building ZoomableImageInListView(dirty, state:
I/flutter (13594): _ZoomableImageInListViewState#39144):
I/flutter (13594): type '(BuildContext, int) => dynamic' is not a subtype of type '(BuildContext, int) => Widget'
I/flutter (13594): 
I/flutter (13594): Either the assertion indicates an error in the framework itself, or we should provide substantially
I/flutter (13594): more information in this error message to help you determine and fix the underlying cause.
I/flutter (13594): In either case, please report this assertion by filing a bug on GitHub:
I/flutter (13594):   https://github.com/flutter/flutter/issues/new
.
.
.
I/flutter (13594): ════════════════════════════════════════════════════════════════════════════════════════════════════

可缩放图像:

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

void main() {
  runApp(new ZoomableImageInListView());
}

class ZoomableImageInListView extends StatefulWidget {
  @override
  _ZoomableImageInListViewState createState() =>
      new _ZoomableImageInListViewState();
}

final List<String> _urlList = [
  'https://www.w3schools.com/htmL/pic_trulli.jpg',
  'https://www.w3schools.com/htmL/img_girl.jpg',
  'https://www.w3schools.com/htmL/img_chania.jpg',
];

class _ZoomableImageInListViewState extends State<ZoomableImageInListView> {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Zoomable Image In ListView',
      debugShowCheckedModeBanner: false,
      home: new Scaffold(
        body: new Column(
          children: <Widget>[
            new Expanded(
              child: new ListView.builder(
                scrollDirection: Axis.vertical,
                itemBuilder: (context, index) => new ZoomableImage(
                    new NetworkImage(_urlList[index], scale: 1.0)),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

抛出了这个异常:

I/flutter (13594): ══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
I/flutter (13594): The following assertion was thrown building ZoomableImage(dirty, state: _ZoomableImageState#d60f4):
I/flutter (13594): A build function returned null.
I/flutter (13594): The offending widget is: ZoomableImage
I/flutter (13594): Build functions must never return null. To return an empty space that causes the building widget to
I/flutter (13594): fill available room, return "new Container()". To return an empty space that takes as little room as
I/flutter (13594): possible, return "new Container(width: 0.0, height: 0.0)".
.
.
.
I/flutter (13594): ════════════════════════════════════════════════════════════════════════════════════════════════════

我检查了 ListView 之外的两个插件,它们工作得很好。我的实现有什么问题吗?这些插件支持 ListView 吗?如果答案是肯定的,请告诉我怎么做?

标签: imageflutterdartscalegesture-recognition

解决方案


如果我错了,请纠正我,但从堆栈跟踪来看,我认为您的问题是您试图在父级中添加一个大小未知的子级,其大小也未知,并且颤动无法计算布局。Image要解决此问题,您需要创建一个具有固定大小的小部件(例如,在您的情况下,可能从其子项的初始状态计算),例如ClipRect.
虽然这解决了错误;它会给您留下一个故障行为,因为在您的情况下,我们面临着此处提到的手势消歧,这意味着您有多个手势检测器试图同时识别特定手势。确切地说,一个处理超集的scalepan用于缩放和平移图像,以及drag用于在ListView. 为了克服这个问题,我认为您需要实现一个小部件来控制输入手势并手动决定是在手势竞技场中宣布胜利还是宣布失败。为了实现所需的行为,
我附上了我在这里那里找到的几行代码,对于这个特定的示例,您将需要flutter_advanced_networkimage库,但您可以将 AdvancedNetworkImage 替换为其他小部件:

ZoomableCachedNetworkImage:

class ZoomableCachedNetworkImage extends StatelessWidget {
  String url;
  ImageProvider imageProvider;

  ZoomableCachedNetworkImage(this.url) {
    imageProvider = _loadImageProvider();
  }

  @override
  Widget build(BuildContext context) {
    return new ZoomablePhotoViewer(
      url: url,
    );
  }
  
  ImageProvider _loadImageProvider() {
    return new AdvancedNetworkImage(this.url);
  }
}

class ZoomablePhotoViewer extends StatefulWidget {
  const ZoomablePhotoViewer({Key key, this.url}) : super(key: key);

  final String url;

  @override
  _ZoomablePhotoViewerState createState() => new _ZoomablePhotoViewerState();
}

class _ZoomablePhotoViewerState extends State<ZoomablePhotoViewer>
    with SingleTickerProviderStateMixin {
  AnimationController _controller;
  Animation<Offset> _flingAnimation;
  Offset _offset = Offset.zero;
  double _scale = 1.0;
  Offset _normalizedOffset;
  double _previousScale;
  HitTestBehavior behavior;

  @override
  void initState() {
    super.initState();
    _controller = new AnimationController(vsync: this)
      ..addListener(_handleFlingAnimation);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  // The maximum offset value is 0,0. If the size of this renderer's box is w,h
  // then the minimum offset value is w - _scale * w, h - _scale * h.
  Offset _clampOffset(Offset offset) {
    final Size size = context.size;
    final Offset minOffset =
        new Offset(size.width, size.height) * (1.0 - _scale);
    return new Offset(
        offset.dx.clamp(minOffset.dx, 0.0), offset.dy.clamp(minOffset.dy, 0.0));
  }

  void _handleFlingAnimation() {
    setState(() {
      _offset = _flingAnimation.value;
    });
  }

  void _handleOnScaleStart(ScaleStartDetails details) {
    setState(() {
      _previousScale = _scale;
      _normalizedOffset = (details.focalPoint - _offset) / _scale;
      // The fling animation stops if an input gesture starts.
      _controller.stop();
    });
  }

  void _handleOnScaleUpdate(ScaleUpdateDetails details) {
    setState(() {
      _scale = (_previousScale * details.scale).clamp(1.0, 4.0);
      // Ensure that image location under the focal point stays in the same place despite scaling.
      _offset = _clampOffset(details.focalPoint - _normalizedOffset * _scale);
    });
  }

  void _handleOnScaleEnd(ScaleEndDetails details) {
    const double _kMinFlingVelocity = 800.0;
    final double magnitude = details.velocity.pixelsPerSecond.distance;
    print('magnitude: ' + magnitude.toString());
    if (magnitude < _kMinFlingVelocity) return;
    final Offset direction = details.velocity.pixelsPerSecond / magnitude;
    final double distance = (Offset.zero & context.size).shortestSide;
    _flingAnimation = new Tween<Offset>(
            begin: _offset, end: _clampOffset(_offset + direction * distance))
        .animate(_controller);
    _controller
      ..value = 0.0
      ..fling(velocity: magnitude / 1000.0);
  }

  @override
  Widget build(BuildContext context) {
    return RawGestureDetector(
      gestures: {
        AllowMultipleScaleRecognizer:
            GestureRecognizerFactoryWithHandlers<AllowMultipleScaleRecognizer>(
          () => AllowMultipleScaleRecognizer(), //constructor
          (AllowMultipleScaleRecognizer instance) {
            //initializer
            instance.onStart = (details) => this._handleOnScaleStart(details);
            instance.onEnd = (details) => this._handleOnScaleEnd(details);
            instance.onUpdate = (details) => this._handleOnScaleUpdate(details);
          },
        ),
        AllowMultipleHorizontalDragRecognizer:
            GestureRecognizerFactoryWithHandlers<AllowMultipleHorizontalDragRecognizer>(
          () => AllowMultipleHorizontalDragRecognizer(),
          (AllowMultipleHorizontalDragRecognizer instance) {
            instance.onStart = (details) => this._handleHorizontalDragAcceptPolicy(instance);
            instance.onUpdate = (details) => this._handleHorizontalDragAcceptPolicy(instance);
          },
        ),
        AllowMultipleVerticalDragRecognizer:
            GestureRecognizerFactoryWithHandlers<AllowMultipleVerticalDragRecognizer>(
          () => AllowMultipleVerticalDragRecognizer(),
          (AllowMultipleVerticalDragRecognizer instance) {
            instance.onStart = (details) => this._handleVerticalDragAcceptPolicy(instance);
            instance.onUpdate = (details) => this._handleVerticalDragAcceptPolicy(instance);
          },
        ),
      },
      //Creates the nested container within the first.
      behavior: HitTestBehavior.opaque,
      child: new ClipRect(
        child: new Transform(
          transform: new Matrix4.identity()
            ..translate(_offset.dx, _offset.dy)
            ..scale(_scale),
          child: Image(
            image: new AdvancedNetworkImage(widget.url),
            fit: BoxFit.cover,
          ),
        ),
      ),
    );
  }

  void _handleHorizontalDragAcceptPolicy(AllowMultipleHorizontalDragRecognizer instance) {
    _scale > 1.0 ? instance.alwaysAccept = true : instance.alwaysAccept = false;
  }

 void _handleVerticalDragAcceptPolicy(AllowMultipleVerticalDragRecognizer instance) {
   _scale > 1.0 ? instance.alwaysAccept = true : instance.alwaysAccept = false;
 }
}

AllowMultipleVerticalDragRecognizer:

import 'package:flutter/gestures.dart';

class AllowMultipleVerticalDragRecognizer extends VerticalDragGestureRecognizer {
  bool alwaysAccept;

  @override
  void rejectGesture(int pointer) {
    acceptGesture(pointer);
  }

  @override
  void resolve(GestureDisposition disposition) {
    if(alwaysAccept) {
      super.resolve(GestureDisposition.accepted);
    } else {
      super.resolve(GestureDisposition.rejected);
    }
  }
}

AllowMultipleHorizo​​ntalDragRecognizer:

import 'package:flutter/gestures.dart';

class AllowMultipleHorizontalDragRecognizer extends HorizontalDragGestureRecognizer {
  bool alwaysAccept;

  @override
  void rejectGesture(int pointer) {
    acceptGesture(pointer);
  }

  @override
  void resolve(GestureDisposition disposition) {
    if(alwaysAccept) {
      super.resolve(GestureDisposition.accepted);
    } else {
      super.resolve(GestureDisposition.rejected);
    }
  }
}

AllowMultipleScaleRecognizer

import 'package:flutter/gestures.dart';

class AllowMultipleScaleRecognizer extends ScaleGestureRecognizer {
  @override
  void rejectGesture(int pointer) {
    acceptGesture(pointer);
  }
}

然后像这样使用它:

@override
Widget build(BuildContext context) {
  return new MaterialApp(
    title: 'Zoomable Image In ListView',
    debugShowCheckedModeBanner: false,
    home: new Scaffold(
      body: new Column(
        children: <Widget>[
          new Expanded(
            child: new ListView.builder(
              scrollDirection: Axis.vertical,
              itemBuilder: (context, index) => ZoomableCachedNetworkImage(_urlList[index]),
            ),
          ),
        ],
      ),
    ),
  );
}

我希望这有帮助。

更新:

根据评论中的要求,为了支持双击,您应该进行以下更改:

AllowMultipleDoubleTapRecognizer:

import 'package:flutter/gestures.dart';

class AllowMultipleDoubleTapRecognizer extends DoubleTapGestureRecognizer {
  @override
  void rejectGesture(int pointer) {
    acceptGesture(pointer);
  }
}

AllowMultipleTapRecognizer

import 'package:flutter/gestures.dart';

class AllowMultipleTapRecognizer extends TapGestureRecognizer {
  @override
  void rejectGesture(int pointer) {
    acceptGesture(pointer);
  }
}

ZoomableCachedNetworkImage

class ZoomableCachedNetworkImage extends StatelessWidget {
  final String url;
  final bool closeOnZoomOut;
  final Offset focalPoint;
  final double initialScale;
  final bool animateToInitScale;

  ZoomableCachedNetworkImage({
    this.url,
    this.closeOnZoomOut = false,
    this.focalPoint,
    this.initialScale,
    this.animateToInitScale,
  });

  Widget loadImage() {
    return ZoomablePhotoViewer(
      url: url,
      closeOnZoomOut: closeOnZoomOut,
      focalPoint: focalPoint,
      initialScale: initialScale,
      animateToInitScale: animateToInitScale,
    );
  }
}

class ZoomablePhotoViewer extends StatefulWidget {
  const ZoomablePhotoViewer({
    Key key,
    this.url,
    this.closeOnZoomOut,
    this.focalPoint,
    this.initialScale,
    this.animateToInitScale,
  }) : super(key: key);

  final String url;
  final bool closeOnZoomOut;
  final Offset focalPoint;
  final double initialScale;
  final bool animateToInitScale;

  @override
  _ZoomablePhotoViewerState createState() => _ZoomablePhotoViewerState(url,
      closeOnZoomOut: closeOnZoomOut,
      focalPoint: focalPoint,
      animateToInitScale: animateToInitScale,
      initialScale: initialScale);
}

class _ZoomablePhotoViewerState extends State<ZoomablePhotoViewer>
    with TickerProviderStateMixin {
  static const double _minScale = 0.99;
  static const double _maxScale = 4.0;
  AnimationController _flingAnimationController;
  Animation<Offset> _flingAnimation;
  AnimationController _zoomAnimationController;
  Animation<double> _zoomAnimation;
  Offset _offset;
  double _scale;
  Offset _normalizedOffset;
  double _previousScale;
  AllowMultipleHorizontalDragRecognizer _allowMultipleHorizontalDragRecognizer;
  AllowMultipleVerticalDragRecognizer _allowMultipleVerticalDragRecognizer;
  Offset _tapDownGlobalPosition;
  String _url;
  bool _closeOnZoomOut;
  Offset _focalPoint;
  bool _animateToInitScale;
  double _initialScale;

  _ZoomablePhotoViewerState(
    String url, {
    bool closeOnZoomOut = false,
    Offset focalPoint = Offset.zero,
    double initialScale = 1.0,
    bool animateToInitScale = false,
  }) {
    this._url = url;
    this._closeOnZoomOut = closeOnZoomOut;
    this._offset = Offset.zero;
    this._scale = 1.0;
    this._initialScale = initialScale;
    this._focalPoint = focalPoint;
    this._animateToInitScale = animateToInitScale;
  }

  @override
  void initState() {
    super.initState();
    if (_animateToInitScale) {
      WidgetsBinding.instance.addPostFrameCallback(
          (_) => _zoom(_focalPoint, _initialScale, context));
    }
    _flingAnimationController = AnimationController(vsync: this)
      ..addListener(_handleFlingAnimation);
    _zoomAnimationController = AnimationController(
        duration: const Duration(milliseconds: 200), vsync: this);
  }

  @override
  void dispose() {
    _flingAnimationController.dispose();
    _zoomAnimationController.dispose();
    super.dispose();
  }

  // The maximum offset value is 0,0. If the size of this renderer's box is w,h
  // then the minimum offset value is w - _scale * w, h - _scale * h.
  Offset _clampOffset(Offset offset) {
    final Size size = context.size;
    final Offset minOffset = Offset(size.width, size.height) * (1.0 - _scale);
    return Offset(
        offset.dx.clamp(minOffset.dx, 0.0), offset.dy.clamp(minOffset.dy, 0.0));
  }

  void _handleFlingAnimation() {
    setState(() {
      _offset = _flingAnimation.value;
    });
  }

  void _handleOnScaleStart(ScaleStartDetails details) {
    setState(() {
      _previousScale = _scale;
      _normalizedOffset = (details.focalPoint - _offset) / _scale;
      // The fling animation stops if an input gesture starts.
      _flingAnimationController.stop();
    });
  }

  void _handleOnScaleUpdate(ScaleUpdateDetails details) {
    if (_scale < 1.0 && _closeOnZoomOut) {
      _zoom(Offset.zero, 1.0, context);
      Navigator.pop(context);
      return;
    }
    setState(() {
      _scale = (_previousScale * details.scale).clamp(_minScale, _maxScale);
      // Ensure that image location under the focal point stays in the same place despite scaling.
      _offset = _clampOffset(details.focalPoint - _normalizedOffset * _scale);
    });
  }

  void _handleOnScaleEnd(ScaleEndDetails details) {
    const double _kMinFlingVelocity = 2000.0;
    final double magnitude = details.velocity.pixelsPerSecond.distance;
//    print('magnitude: ' + magnitude.toString());
    if (magnitude < _kMinFlingVelocity) return;
    final Offset direction = details.velocity.pixelsPerSecond / magnitude;
    final double distance = (Offset.zero & context.size).shortestSide;
    _flingAnimation = Tween<Offset>(
            begin: _offset, end: _clampOffset(_offset + direction * distance))
        .animate(_flingAnimationController);
    _flingAnimationController
      ..value = 0.0
      ..fling(velocity: magnitude / 2000.0);
  }

  @override
  Widget build(BuildContext context) {
    return RawGestureDetector(
      gestures: {
        AllowMultipleScaleRecognizer:
            GestureRecognizerFactoryWithHandlers<AllowMultipleScaleRecognizer>(
          () => AllowMultipleScaleRecognizer(), //constructor
          (AllowMultipleScaleRecognizer instance) {
            //initializer
            instance.onStart = (details) => this._handleOnScaleStart(details);
            instance.onEnd = (details) => this._handleOnScaleEnd(details);
            instance.onUpdate = (details) => this._handleOnScaleUpdate(details);
          },
        ),
        AllowMultipleHorizontalDragRecognizer:
            GestureRecognizerFactoryWithHandlers<
                AllowMultipleHorizontalDragRecognizer>(
          () => AllowMultipleHorizontalDragRecognizer(),
          (AllowMultipleHorizontalDragRecognizer instance) {
            _allowMultipleHorizontalDragRecognizer = instance;
            instance.onStart =
                (details) => this._handleHorizontalDragAcceptPolicy(instance);
            instance.onUpdate =
                (details) => this._handleHorizontalDragAcceptPolicy(instance);
          },
        ),
        AllowMultipleVerticalDragRecognizer:
            GestureRecognizerFactoryWithHandlers<
                AllowMultipleVerticalDragRecognizer>(
          () => AllowMultipleVerticalDragRecognizer(),
          (AllowMultipleVerticalDragRecognizer instance) {
            _allowMultipleVerticalDragRecognizer = instance;
            instance.onStart =
                (details) => this._handleVerticalDragAcceptPolicy(instance);
            instance.onUpdate =
                (details) => this._handleVerticalDragAcceptPolicy(instance);
          },
        ),
        AllowMultipleDoubleTapRecognizer: GestureRecognizerFactoryWithHandlers<
            AllowMultipleDoubleTapRecognizer>(
          () => AllowMultipleDoubleTapRecognizer(),
          (AllowMultipleDoubleTapRecognizer instance) {
            instance.onDoubleTap = () => this._handleDoubleTap();
          },
        ),
        AllowMultipleTapRecognizer:
            GestureRecognizerFactoryWithHandlers<AllowMultipleTapRecognizer>(
          () => AllowMultipleTapRecognizer(),
          (AllowMultipleTapRecognizer instance) {
            instance.onTapDown =
                (details) => this._handleTapDown(details.globalPosition);
          },
        ),
      },
      //Creates the nested container within the first.
      behavior: HitTestBehavior.opaque,
      child: Transform(
        transform: Matrix4.identity()
          ..translate(_offset.dx, _offset.dy)
          ..scale(_scale),
        child: _buildTransitionToImage(),
      ),
    );
  }

  Widget _buildTransitionToImage() {
    return CachedNetworkImage(
      imageUrl: this._url,
      fit: BoxFit.contain,
      fadeOutDuration: Duration(milliseconds: 0),
      fadeInDuration: Duration(milliseconds: 0),
    );
  }

  void _handleHorizontalDragAcceptPolicy(
      AllowMultipleHorizontalDragRecognizer instance) {
    _scale != 1.0
        ? instance.alwaysAccept = true
        : instance.alwaysAccept = false;
  }

  void _handleVerticalDragAcceptPolicy(
      AllowMultipleVerticalDragRecognizer instance) {
    _scale != 1.0
        ? instance.alwaysAccept = true
        : instance.alwaysAccept = false;
  }

  void _handleDoubleTap() {
    setState(() {
      if (_scale >= 1.0 && _scale <= 1.2) {
        _previousScale = _scale;
        _normalizedOffset = (_tapDownGlobalPosition - _offset) / _scale;
        _scale = 2.75;
        _offset = _clampOffset(
            context.size.center(Offset.zero) - _normalizedOffset * _scale);
        _allowMultipleVerticalDragRecognizer.alwaysAccept = true;
        _allowMultipleHorizontalDragRecognizer.alwaysAccept = true;
      } else {
        if (_closeOnZoomOut) {
          _zoom(Offset.zero, 1.0, context);
          _zoomAnimation.addListener(() {
            if (_zoomAnimation.isCompleted) {
              Navigator.pop(context);
            }
          });
          return;
        }
        _scale = 1.0;
        _offset = _clampOffset(Offset.zero - _normalizedOffset * _scale);
        _allowMultipleVerticalDragRecognizer.alwaysAccept = false;
        _allowMultipleHorizontalDragRecognizer.alwaysAccept = false;
      }
    });
  }

  _handleTapDown(Offset globalPosition) {
    final RenderBox referenceBox = context.findRenderObject();
    _tapDownGlobalPosition = referenceBox.globalToLocal(globalPosition);
  }

  _zoom(Offset focalPoint, double scale, BuildContext context) {
    final RenderBox referenceBox = context.findRenderObject();
    focalPoint = referenceBox.globalToLocal(focalPoint);
    _previousScale = _scale;
    _normalizedOffset = (focalPoint - _offset) / _scale;
    _allowMultipleVerticalDragRecognizer.alwaysAccept = true;
    _allowMultipleHorizontalDragRecognizer.alwaysAccept = true;
    _zoomAnimation = Tween<double>(begin: _scale, end: scale)
        .animate(_zoomAnimationController);
    _zoomAnimation.addListener(() {
      setState(() {
        _scale = _zoomAnimation.value;
        _offset = scale < _scale
            ? _clampOffset(Offset.zero - _normalizedOffset * _scale)
            : _clampOffset(
                context.size.center(Offset.zero) - _normalizedOffset * _scale);
      });
    });
    _zoomAnimationController.forward(from: 0.0);
  }
}

abstract class ScaleDownHandler {
  void handleScaleDown();
}

推荐阅读