Fix visibility of text selection handlers on scroll

pull/430/head
li3317 4 years ago
parent c7158b5507
commit 4f0fa755b1
  1. 50
      lib/src/widgets/editor.dart
  2. 369
      lib/src/widgets/quill_single_child_scroll_view.dart
  3. 25
      lib/src/widgets/raw_editor.dart
  4. 3
      lib/src/widgets/simple_viewer.dart

@ -675,6 +675,7 @@ typedef TextSelectionChangedHandler = void Function(
class RenderEditor extends RenderEditableContainerBox
implements RenderAbstractEditor {
RenderEditor(
ViewportOffset? offset,
List<RenderEditableBox>? children,
TextDirection textDirection,
double scrollBottomInset,
@ -709,6 +710,41 @@ class RenderEditor extends RenderEditableContainerBox
ValueListenable<bool> get selectionEndInViewport => _selectionEndInViewport;
final ValueNotifier<bool> _selectionEndInViewport = ValueNotifier<bool>(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));
}

@ -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<RenderBox>
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<ClipRectLayer>();
@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,
);
}
}

@ -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

@ -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,

Loading…
Cancel
Save