From 4f0fa755b15130abfda1956a37f7505e4cfe2092 Mon Sep 17 00:00:00 2001 From: li3317 Date: Sat, 23 Oct 2021 22:18:14 -0400 Subject: [PATCH] Fix visibility of text selection handlers on scroll --- lib/src/widgets/editor.dart | 50 +++ .../quill_single_child_scroll_view.dart | 369 ++++++++++++++++++ lib/src/widgets/raw_editor.dart | 25 +- lib/src/widgets/simple_viewer.dart | 3 + 4 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 lib/src/widgets/quill_single_child_scroll_view.dart diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index 8a1e2b79..48524ca1 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.dart @@ -675,6 +675,7 @@ typedef TextSelectionChangedHandler = void Function( class RenderEditor extends RenderEditableContainerBox implements RenderAbstractEditor { RenderEditor( + ViewportOffset? offset, List? children, TextDirection textDirection, double scrollBottomInset, @@ -709,6 +710,41 @@ class RenderEditor extends RenderEditableContainerBox ValueListenable get selectionEndInViewport => _selectionEndInViewport; final ValueNotifier _selectionEndInViewport = ValueNotifier(true); + void _updateSelectionExtentsVisibility(Offset effectiveOffset) { + final visibleRegion = Offset.zero & size; + final startPosition = + TextPosition(offset: selection.start, affinity: selection.affinity); + final startOffset = _getOffsetForCaret(startPosition); + // TODO(justinmc): https://github.com/flutter/flutter/issues/31495 + // Check if the selection is visible with an approximation because a + // difference between rounded and unrounded values causes the caret to be + // reported as having a slightly (< 0.5) negative y offset. This rounding + // happens in paragraph.cc's layout and TextPainer's + // _applyFloatingPointHack. Ideally, the rounding mismatch will be fixed and + // this can be changed to be a strict check instead of an approximation. + const visibleRegionSlop = 0.5; + _selectionStartInViewport.value = visibleRegion + .inflate(visibleRegionSlop) + .contains(startOffset + effectiveOffset); + + final endPosition = + TextPosition(offset: selection.end, affinity: selection.affinity); + final endOffset = _getOffsetForCaret(endPosition); + _selectionEndInViewport.value = visibleRegion + .inflate(visibleRegionSlop) + .contains(endOffset + effectiveOffset); + } + + // returns offset relative to this at which the caret will be painted + // given a global TextPosition + Offset _getOffsetForCaret(TextPosition position) { + final child = childAtPosition(position); + final childPosition = child.globalToLocalPosition(position); + final boxParentData = child.parentData as BoxParentData; + final localOffsetForCaret = child.getOffsetForCaret(childPosition); + return boxParentData.offset + localOffsetForCaret; + } + void setDocument(Document doc) { if (document == doc) { return; @@ -725,6 +761,19 @@ class RenderEditor extends RenderEditableContainerBox markNeedsSemanticsUpdate(); } + Offset get _paintOffset => Offset(0, -(offset?.pixels ?? 0.0)); + + ViewportOffset? get offset => _offset; + ViewportOffset? _offset; + + set offset(ViewportOffset? value) { + if (_offset == value) return; + if (attached) _offset?.removeListener(markNeedsPaint); + _offset = value; + if (attached) _offset?.addListener(markNeedsPaint); + markNeedsLayout(); + } + void setSelection(TextSelection t) { if (selection == t) { return; @@ -958,6 +1007,7 @@ class RenderEditor extends RenderEditableContainerBox @override void paint(PaintingContext context, Offset offset) { defaultPaint(context, offset); + _updateSelectionExtentsVisibility(offset + _paintOffset); _paintHandleLayers(context, getEndpointsForSelection(selection)); } diff --git a/lib/src/widgets/quill_single_child_scroll_view.dart b/lib/src/widgets/quill_single_child_scroll_view.dart new file mode 100644 index 00000000..df5cd220 --- /dev/null +++ b/lib/src/widgets/quill_single_child_scroll_view.dart @@ -0,0 +1,369 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Very similar to [SingleChildView] but with a [ViewportBuilder] argument +/// instead of a [Widget] +/// +/// Useful when child needs [ViewportOffset] (e.g. [RenderEditor]) +/// see: [SingleChildScrollView] +class QuillSingleChildScrollView extends StatelessWidget { + /// Creates a box in which a single widget can be scrolled. + const QuillSingleChildScrollView({ + required this.controller, + required this.viewportBuilder, + Key? key, + this.physics, + this.restorationId, + }) : super(key: key); + + /// An object that can be used to control the position to which this scroll + /// view is scrolled. + /// + /// Must be null if [primary] is true. + /// + /// A [ScrollController] serves several purposes. It can be used to control + /// the initial scroll position (see [ScrollController.initialScrollOffset]). + /// It can be used to control whether the scroll view should automatically + /// save and restore its scroll position in the [PageStorage] (see + /// [ScrollController.keepScrollOffset]). It can be used to read the current + /// scroll position (see [ScrollController.offset]), or change it (see + /// [ScrollController.animateTo]). + final ScrollController controller; + + /// How the scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// Defaults to matching platform conventions. + final ScrollPhysics? physics; + + /// {@macro flutter.widgets.scrollable.restorationId} + final String? restorationId; + + final ViewportBuilder viewportBuilder; + + AxisDirection _getDirection(BuildContext context) { + return getAxisDirectionFromAxisReverseAndDirectionality( + context, Axis.vertical, false); + } + + @override + Widget build(BuildContext context) { + final axisDirection = _getDirection(context); + final scrollController = controller; + final scrollable = Scrollable( + axisDirection: axisDirection, + controller: scrollController, + physics: physics, + restorationId: restorationId, + viewportBuilder: (context, offset) { + return _SingleChildViewport( + offset: offset, + child: viewportBuilder(context, offset), + ); + }, + ); + return scrollable; + } +} + +class _SingleChildViewport extends SingleChildRenderObjectWidget { + const _SingleChildViewport({ + required this.offset, + Key? key, + Widget? child, + }) : super(key: key, child: child); + + final ViewportOffset offset; + + @override + _RenderSingleChildViewport createRenderObject(BuildContext context) { + return _RenderSingleChildViewport( + offset: offset, + ); + } + + @override + void updateRenderObject( + BuildContext context, _RenderSingleChildViewport renderObject) { + // Order dependency: The offset setter reads the axis direction. + renderObject.offset = offset; + } +} + +class _RenderSingleChildViewport extends RenderBox + with RenderObjectWithChildMixin + implements RenderAbstractViewport { + _RenderSingleChildViewport({ + required ViewportOffset offset, + double cacheExtent = RenderAbstractViewport.defaultCacheExtent, + RenderBox? child, + }) : _offset = offset, + _cacheExtent = cacheExtent { + this.child = child; + } + + ViewportOffset get offset => _offset; + ViewportOffset _offset; + + set offset(ViewportOffset value) { + if (value == _offset) return; + if (attached) _offset.removeListener(_hasScrolled); + _offset = value; + if (attached) _offset.addListener(_hasScrolled); + markNeedsLayout(); + } + + /// {@macro flutter.rendering.RenderViewportBase.cacheExtent} + double get cacheExtent => _cacheExtent; + double _cacheExtent; + + set cacheExtent(double value) { + if (value == _cacheExtent) return; + _cacheExtent = value; + markNeedsLayout(); + } + + void _hasScrolled() { + markNeedsPaint(); + markNeedsSemanticsUpdate(); + } + + @override + void setupParentData(RenderObject child) { + // We don't actually use the offset argument in BoxParentData, so let's + // avoid allocating it at all. + if (child.parentData is! ParentData) child.parentData = ParentData(); + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _offset.addListener(_hasScrolled); + } + + @override + void detach() { + _offset.removeListener(_hasScrolled); + super.detach(); + } + + @override + bool get isRepaintBoundary => true; + + double get _viewportExtent { + assert(hasSize); + return size.height; + } + + double get _minScrollExtent { + assert(hasSize); + return 0; + } + + double get _maxScrollExtent { + assert(hasSize); + if (child == null) return 0; + return math.max(0, child!.size.height - size.height); + } + + BoxConstraints _getInnerConstraints(BoxConstraints constraints) { + return constraints.widthConstraints(); + } + + @override + double computeMinIntrinsicWidth(double height) { + if (child != null) return child!.getMinIntrinsicWidth(height); + return 0; + } + + @override + double computeMaxIntrinsicWidth(double height) { + if (child != null) return child!.getMaxIntrinsicWidth(height); + return 0; + } + + @override + double computeMinIntrinsicHeight(double width) { + if (child != null) return child!.getMinIntrinsicHeight(width); + return 0; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (child != null) return child!.getMaxIntrinsicHeight(width); + return 0; + } + + // We don't override computeDistanceToActualBaseline(), because we + // want the default behavior (returning null). Otherwise, as you + // scroll, it would shift in its parent if the parent was baseline-aligned, + // which makes no sense. + + @override + Size computeDryLayout(BoxConstraints constraints) { + if (child == null) { + return constraints.smallest; + } + final childSize = child!.getDryLayout(_getInnerConstraints(constraints)); + return constraints.constrain(childSize); + } + + @override + void performLayout() { + final constraints = this.constraints; + if (child == null) { + size = constraints.smallest; + } else { + child!.layout(_getInnerConstraints(constraints), parentUsesSize: true); + size = constraints.constrain(child!.size); + } + + offset.applyViewportDimension(_viewportExtent); + offset.applyContentDimensions(_minScrollExtent, _maxScrollExtent); + } + + Offset get _paintOffset => _paintOffsetForPosition(offset.pixels); + + Offset _paintOffsetForPosition(double position) { + return Offset(0, -position); + } + + bool _shouldClipAtPaintOffset(Offset paintOffset) { + assert(child != null); + return paintOffset.dx < 0 || + paintOffset.dy < 0 || + paintOffset.dx + child!.size.width > size.width || + paintOffset.dy + child!.size.height > size.height; + } + + @override + void paint(PaintingContext context, Offset offset) { + if (child != null) { + final paintOffset = _paintOffset; + + void paintContents(PaintingContext context, Offset offset) { + context.paintChild(child!, offset + paintOffset); + } + + if (_shouldClipAtPaintOffset(paintOffset)) { + _clipRectLayer.layer = context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + paintContents, + oldLayer: _clipRectLayer.layer, + ); + } else { + _clipRectLayer.layer = null; + paintContents(context, offset); + } + } + } + + final _clipRectLayer = LayerHandle(); + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + final paintOffset = _paintOffset; + transform.translate(paintOffset.dx, paintOffset.dy); + } + + @override + Rect? describeApproximatePaintClip(RenderObject? child) { + if (child != null && _shouldClipAtPaintOffset(_paintOffset)) { + return Offset.zero & size; + } + return null; + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + if (child != null) { + return result.addWithPaintOffset( + offset: _paintOffset, + position: position, + hitTest: (result, transformed) { + assert(transformed == position + -_paintOffset); + return child!.hitTest(result, position: transformed); + }, + ); + } + return false; + } + + @override + RevealedOffset getOffsetToReveal(RenderObject target, double alignment, + {Rect? rect}) { + rect ??= target.paintBounds; + if (target is! RenderBox) { + return RevealedOffset(offset: offset.pixels, rect: rect); + } + + final targetBox = target; + final transform = targetBox.getTransformTo(child); + final bounds = MatrixUtils.transformRect(transform, rect); + + final double leadingScrollOffset; + final double targetMainAxisExtent; + final double mainAxisExtent; + + mainAxisExtent = size.height; + leadingScrollOffset = bounds.top; + targetMainAxisExtent = bounds.height; + + final targetOffset = leadingScrollOffset - + (mainAxisExtent - targetMainAxisExtent) * alignment; + final targetRect = bounds.shift(_paintOffsetForPosition(targetOffset)); + return RevealedOffset(offset: targetOffset, rect: targetRect); + } + + @override + void showOnScreen({ + RenderObject? descendant, + Rect? rect, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + }) { + if (!offset.allowImplicitScrolling) { + return super.showOnScreen( + descendant: descendant, + rect: rect, + duration: duration, + curve: curve, + ); + } + + final newRect = RenderViewportBase.showInViewport( + descendant: descendant, + viewport: this, + offset: offset, + rect: rect, + duration: duration, + curve: curve, + ); + super.showOnScreen( + rect: newRect, + duration: duration, + curve: curve, + ); + } + + @override + Rect describeSemanticsClip(RenderObject child) { + return Rect.fromLTRB( + semanticBounds.left, + semanticBounds.top - cacheExtent, + semanticBounds.right, + semanticBounds.bottom + cacheExtent, + ); + } +} diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index 899ba5c3..e9502678 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -23,6 +23,7 @@ import 'delegate.dart'; import 'editor.dart'; import 'keyboard_listener.dart'; import 'proxy.dart'; +import 'quill_single_child_scroll_view.dart'; import 'raw_editor/raw_editor_state_keyboard_mixin.dart'; import 'raw_editor/raw_editor_state_selection_delegate_mixin.dart'; import 'raw_editor/raw_editor_state_text_input_client_mixin.dart'; @@ -174,10 +175,26 @@ class RawEditorState extends EditorState child = BaselineProxy( textStyle: _styles!.paragraph!.style, padding: baselinePadding, - child: SingleChildScrollView( + child: QuillSingleChildScrollView( controller: _scrollController, physics: widget.scrollPhysics, - child: child, + viewportBuilder: (_, offset) => CompositedTransformTarget( + link: _toolbarLayerLink, + child: _Editor( + key: _editorKey, + offset: offset, + document: widget.controller.document, + selection: widget.controller.selection, + hasFocus: _hasFocus, + textDirection: _textDirection, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + onSelectionChanged: _handleSelectionChanged, + scrollBottomInset: widget.scrollBottomInset, + padding: widget.padding, + children: _buildChildren(_doc, context), + ), + ), ), ); } @@ -717,8 +734,10 @@ class _Editor extends MultiChildRenderObjectWidget { required this.onSelectionChanged, required this.scrollBottomInset, this.padding = EdgeInsets.zero, + this.offset, }) : super(key: key, children: children); + final ViewportOffset? offset; final Document document; final TextDirection textDirection; final bool hasFocus; @@ -732,6 +751,7 @@ class _Editor extends MultiChildRenderObjectWidget { @override RenderEditor createRenderObject(BuildContext context) { return RenderEditor( + offset, null, textDirection, scrollBottomInset, @@ -750,6 +770,7 @@ class _Editor extends MultiChildRenderObjectWidget { void updateRenderObject( BuildContext context, covariant RenderEditor renderObject) { renderObject + ..offset = offset ..document = document ..setContainer(document.root) ..textDirection = textDirection diff --git a/lib/src/widgets/simple_viewer.dart b/lib/src/widgets/simple_viewer.dart index 8d59686a..dc68fe2a 100644 --- a/lib/src/widgets/simple_viewer.dart +++ b/lib/src/widgets/simple_viewer.dart @@ -316,10 +316,12 @@ class _SimpleViewer extends MultiChildRenderObjectWidget { required this.endHandleLayerLink, required this.onSelectionChanged, required this.scrollBottomInset, + this.offset, this.padding = EdgeInsets.zero, Key? key, }) : super(key: key, children: children); + final ViewportOffset? offset; final Document document; final TextDirection textDirection; final LayerLink startHandleLayerLink; @@ -331,6 +333,7 @@ class _SimpleViewer extends MultiChildRenderObjectWidget { @override RenderEditor createRenderObject(BuildContext context) { return RenderEditor( + offset, null, textDirection, scrollBottomInset,