text_selection.dart 20.2 KB
Newer Older
1 2 3 4
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

xster's avatar
xster committed
5 6
import 'dart:async';

7
import 'package:flutter/foundation.dart';
8
import 'package:flutter/rendering.dart';
9
import 'package:flutter/services.dart';
10
import 'package:flutter/scheduler.dart';
11 12

import 'basic.dart';
13
import 'container.dart';
14
import 'editable_text.dart';
15 16 17
import 'framework.dart';
import 'gesture_detector.dart';
import 'overlay.dart';
18
import 'transitions.dart';
19

20 21 22 23
/// Which type of selection handle to be displayed.
///
/// With mixed-direction text, both handles may be the same type. Examples:
///
24 25 26 27 28
/// * LTR text: 'the <quick brown> fox':
///
///   The '<' is drawn with the [left] type, the '>' with the [right]
///
/// * RTL text: 'XOF <NWORB KCIUQ> EHT':
29
///
30
///   Same as above.
31
///
32 33 34 35 36 37 38 39 40
/// * mixed text: '<the NWOR<B KCIUQ fox'
///
///   Here 'the QUICK B' is selected, but 'QUICK BROWN' is RTL. Both are drawn
///   with the [left] type.
///
/// See also:
///
///  * [TextDirection], which discusses left-to-right and right-to-left text in
///    more detail.
41 42 43
enum TextSelectionHandleType {
  /// The selection handle is to the left of the selection end point.
  left,
44

45 46 47 48 49 50 51
  /// The selection handle is to the right of the selection end point.
  right,

  /// The start and end of the selection are co-incident at this point.
  collapsed,
}

52 53 54 55
/// The text position that a give selection handle manipulates. Dragging the
/// [start] handle always moves the [start]/[baseOffset] of the selection.
enum _TextSelectionHandlePosition { start, end }

56 57 58 59
/// Signature for reporting changes to the selection component of a
/// [TextEditingValue] for the purposes of a [TextSelectionOverlay]. The
/// [caretRect] argument gives the location of the caret in the coordinate space
/// of the [RenderBox] given by the [TextSelectionOverlay.renderObject].
60 61
///
/// Used by [TextSelectionOverlay.onSelectionOverlayChanged].
62
typedef void TextSelectionOverlayChanged(TextEditingValue value, Rect caretRect);
63

64 65 66 67
/// An interface for manipulating the selection, to be used by the implementor
/// of the toolbar widget.
abstract class TextSelectionDelegate {
  /// Gets the current text input.
68
  TextEditingValue get textEditingValue;
69 70

  /// Sets the current text input (replaces the whole line).
71
  set textEditingValue(TextEditingValue value);
72 73 74

  /// Hides the text selection toolbar.
  void hideToolbar();
75 76 77 78

  /// Brings the provided [TextPosition] into the visible area of the text
  /// input.
  void bringIntoView(TextPosition position);
79 80
}

81 82
/// An interface for building the selection UI, to be provided by the
/// implementor of the toolbar widget.
xster's avatar
xster committed
83 84
///
/// Override text operations such as [handleCut] if needed.
85 86
abstract class TextSelectionControls {
  /// Builds a selection handle of the given type.
xster's avatar
xster committed
87 88 89 90
  ///
  /// The top left corner of this widget is positioned at the bottom of the
  /// selection position.
  Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight);
91 92 93 94

  /// Builds a toolbar near a text selection.
  ///
  /// Typically displays buttons for copying and pasting text.
95
  Widget buildToolbar(BuildContext context, Rect globalEditableRegion, Offset position, TextSelectionDelegate delegate);
96 97 98

  /// Returns the size of the selection handle.
  Size get handleSize;
xster's avatar
xster committed
99

100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132
  /// Whether the current selection of the text field managed by the given
  /// `delegate` can be removed from the text field and placed into the
  /// [Clipboard].
  ///
  /// By default, false is returned when nothing is selected in the text field.
  ///
  /// Subclasses can use this to decide if they should expose the cut
  /// functionality to the user.
  bool canCut(TextSelectionDelegate delegate) {
    return !delegate.textEditingValue.selection.isCollapsed;
  }

  /// Whether the current selection of the text field managed by the given
  /// `delegate` can be copied to the [Clipboard].
  ///
  /// By default, false is returned when nothing is selected in the text field.
  ///
  /// Subclasses can use this to decide if they should expose the copy
  /// functionality to the user.
  bool canCopy(TextSelectionDelegate delegate) {
    return !delegate.textEditingValue.selection.isCollapsed;
  }

  /// Whether the current [Clipboard] content can be pasted into the text field
  /// managed by the given `delegate`.
  ///
  /// Subclasses can use this to decide if they should expose the paste
  /// functionality to the user.
  bool canPaste(TextSelectionDelegate delegate) {
    // TODO(goderbauer): return false when clipboard is empty, https://github.com/flutter/flutter/issues/11254
    return true;
  }

133
  /// Whether the current selection of the text field managed by the given
134 135 136 137 138 139 140 141 142
  /// `delegate` can be extended to include the entire content of the text
  /// field.
  ///
  /// Subclasses can use this to decide if they should expose the select all
  /// functionality to the user.
  bool canSelectAll(TextSelectionDelegate delegate) {
    return delegate.textEditingValue.text.isNotEmpty && delegate.textEditingValue.selection.isCollapsed;
  }

143 144 145 146 147 148
  /// Copy the current selection of the text field managed by the given
  /// `delegate` to the [Clipboard]. Then, remove the selected text from the
  /// text field and hide the toolbar.
  ///
  /// This is called by subclasses when their cut affordance is activated by
  /// the user.
xster's avatar
xster committed
149 150 151 152 153 154 155 156 157 158 159 160
  void handleCut(TextSelectionDelegate delegate) {
    final TextEditingValue value = delegate.textEditingValue;
    Clipboard.setData(new ClipboardData(
      text: value.selection.textInside(value.text),
    ));
    delegate.textEditingValue = new TextEditingValue(
      text: value.selection.textBefore(value.text)
          + value.selection.textAfter(value.text),
      selection: new TextSelection.collapsed(
        offset: value.selection.start
      ),
    );
161
    delegate.bringIntoView(delegate.textEditingValue.selection.extent);
xster's avatar
xster committed
162 163 164
    delegate.hideToolbar();
  }

165 166 167 168 169 170
  /// Copy the current selection of the text field managed by the given
  /// `delegate` to the [Clipboard]. Then, move the cursor to the end of the
  /// text (collapsing the selection in the process), and hide the toolbar.
  ///
  /// This is called by subclasses when their copy affordance is activated by
  /// the user.
xster's avatar
xster committed
171 172 173 174 175 176 177 178 179
  void handleCopy(TextSelectionDelegate delegate) {
    final TextEditingValue value = delegate.textEditingValue;
    Clipboard.setData(new ClipboardData(
      text: value.selection.textInside(value.text),
    ));
    delegate.textEditingValue = new TextEditingValue(
      text: value.text,
      selection: new TextSelection.collapsed(offset: value.selection.end),
    );
180
    delegate.bringIntoView(delegate.textEditingValue.selection.extent);
xster's avatar
xster committed
181 182 183
    delegate.hideToolbar();
  }

184 185 186 187 188 189 190 191 192 193 194
  /// Paste the current clipboard selection (obtained from [Clipboard]) into
  /// the text field managed by the given `delegate`, replacing its current
  /// selection, if any. Then, hide the toolbar.
  ///
  /// This is called by subclasses when their paste affordance is activated by
  /// the user.
  ///
  /// This function is asynchronous since interacting with the clipboard is
  /// asynchronous. Race conditions may exist with this API as currently
  /// implemented.
  // TODO(ianh): https://github.com/flutter/flutter/issues/11427
xster's avatar
xster committed
195
  Future<Null> handlePaste(TextSelectionDelegate delegate) async {
196
    final TextEditingValue value = delegate.textEditingValue; // Snapshot the input before using `await`.
xster's avatar
xster committed
197 198 199 200 201 202 203 204 205 206 207
    final ClipboardData data = await Clipboard.getData(Clipboard.kTextPlain);
    if (data != null) {
      delegate.textEditingValue = new TextEditingValue(
        text: value.selection.textBefore(value.text)
            + data.text
            + value.selection.textAfter(value.text),
        selection: new TextSelection.collapsed(
          offset: value.selection.start + data.text.length
        ),
      );
    }
208
    delegate.bringIntoView(delegate.textEditingValue.selection.extent);
xster's avatar
xster committed
209 210 211
    delegate.hideToolbar();
  }

212 213 214 215 216 217 218
  /// Adjust the selection of the text field managed by the given `delegate` so
  /// that everything is selected.
  ///
  /// Does not hide the toolbar.
  ///
  /// This is called by subclasses when their select-all affordance is activated
  /// by the user.
xster's avatar
xster committed
219 220 221 222 223 224 225 226
  void handleSelectAll(TextSelectionDelegate delegate) {
    delegate.textEditingValue = new TextEditingValue(
      text: delegate.textEditingValue.text,
      selection: new TextSelection(
        baseOffset: 0,
        extentOffset: delegate.textEditingValue.text.length
      ),
    );
227
    delegate.bringIntoView(delegate.textEditingValue.selection.extent);
xster's avatar
xster committed
228
  }
229 230
}

231 232 233 234
/// An object that manages a pair of text selection handles.
///
/// The selection handles are displayed in the [Overlay] that most closely
/// encloses the given [BuildContext].
235
class TextSelectionOverlay {
236 237 238
  /// Creates an object that manages overly entries for selection handles.
  ///
  /// The [context] must not be null and must have an [Overlay] as an ancestor.
239
  TextSelectionOverlay({
240
    @required TextEditingValue value,
241
    @required this.context,
242
    this.debugRequiredFor,
243 244
    @required this.layerLink,
    @required this.renderObject,
245
    this.selectionControls,
246
    this.selectionDelegate,
247 248 249
  }): assert(value != null),
      assert(context != null),
      _value = value {
250 251 252 253
    final OverlayState overlay = Overlay.of(context);
    assert(overlay != null);
    _handleController = new AnimationController(duration: _kFadeDuration, vsync: overlay);
    _toolbarController = new AnimationController(duration: _kFadeDuration, vsync: overlay);
254
  }
255

256 257 258 259
  /// The context in which the selection handles should appear.
  ///
  /// This context must have an [Overlay] as an ancestor because this object
  /// will display the text selection handles in that [Overlay].
260
  final BuildContext context;
261 262

  /// Debugging information for explaining why the [Overlay] is required.
263
  final Widget debugRequiredFor;
264

265 266 267 268
  /// The object supplied to the [CompositedTransformTarget] that wraps the text
  /// field.
  final LayerLink layerLink;

269 270
  // TODO(mpcomplete): what if the renderObject is removed or replaced, or
  // moves? Not sure what cases I need to handle, or how to handle them.
271
  /// The editable line in which the selected text is being displayed.
272
  final RenderEditable renderObject;
273

274 275
  /// Builds text selection handles and toolbar.
  final TextSelectionControls selectionControls;
276

277 278 279 280
  /// The delegate for manipulating the current selection in the owning
  /// text field.
  final TextSelectionDelegate selectionDelegate;

281 282
  /// Controls the fade-in animations.
  static const Duration _kFadeDuration = const Duration(milliseconds: 150);
283 284
  AnimationController _handleController;
  AnimationController _toolbarController;
285 286 287
  Animation<double> get _handleOpacity => _handleController.view;
  Animation<double> get _toolbarOpacity => _toolbarController.view;

288
  TextEditingValue _value;
289 290 291 292 293

  /// A pair of handles. If this is non-null, there are always 2, though the
  /// second is hidden when the selection is collapsed.
  List<OverlayEntry> _handles;

294
  /// A copy/paste toolbar.
295 296
  OverlayEntry _toolbar;

297
  TextSelection get _selection => _value.selection;
298

299
  /// Shows the handles by inserting them into the [context]'s overlay.
300
  void showHandles() {
301 302
    assert(_handles == null);
    _handles = <OverlayEntry>[
303 304
      new OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.start)),
      new OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.end)),
305 306
    ];
    Overlay.of(context, debugRequiredFor: debugRequiredFor).insertAll(_handles);
307
    _handleController.forward(from: 0.0);
308 309 310 311 312 313
  }

  /// Shows the toolbar by inserting it into the [context]'s overlay.
  void showToolbar() {
    assert(_toolbar == null);
    _toolbar = new OverlayEntry(builder: _buildToolbar);
314
    Overlay.of(context, debugRequiredFor: debugRequiredFor).insert(_toolbar);
315
    _toolbarController.forward(from: 0.0);
316 317
  }

318
  /// Updates the overlay after the selection has changed.
319 320
  ///
  /// If this method is called while the [SchedulerBinding.schedulerPhase] is
321 322
  /// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or
  /// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed
323 324 325 326
  /// until the post-frame callbacks phase. Otherwise the update is done
  /// synchronously. This means that it is safe to call during builds, but also
  /// that if you do call this during a build, the UI will not update until the
  /// next frame (i.e. many milliseconds later).
327 328
  void update(TextEditingValue newValue) {
    if (_value == newValue)
329
      return;
330
    _value = newValue;
331 332 333 334 335 336 337
    if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) {
      SchedulerBinding.instance.addPostFrameCallback(_markNeedsBuild);
    } else {
      _markNeedsBuild();
    }
  }

338 339 340 341 342 343 344 345
  /// Causes the overlay to update its rendering.
  ///
  /// This is intended to be called when the [renderObject] may have changed its
  /// text metrics (e.g. because the text was scrolled).
  void updateForScroll() {
    _markNeedsBuild();
  }

346
  void _markNeedsBuild([Duration duration]) {
347 348 349 350 351
    if (_handles != null) {
      _handles[0].markNeedsBuild();
      _handles[1].markNeedsBuild();
    }
    _toolbar?.markNeedsBuild();
352 353
  }

354 355 356 357 358 359
  /// Whether the handles are currently visible.
  bool get handlesAreVisible => _handles != null;

  /// Whether the toolbar is currently visible.
  bool get toolbarIsVisible => _toolbar != null;

360
  /// Hides the overlay.
361
  void hide() {
362 363 364 365 366 367
    if (_handles != null) {
      _handles[0].remove();
      _handles[1].remove();
      _handles = null;
    }
    _toolbar?.remove();
368
    _toolbar = null;
369 370 371 372 373 374 375 376 377 378

    _handleController.stop();
    _toolbarController.stop();
  }

  /// Final cleanup.
  void dispose() {
    hide();
    _handleController.dispose();
    _toolbarController.dispose();
379 380
  }

381
  Widget _buildHandle(BuildContext context, _TextSelectionHandlePosition position) {
382
    if ((_selection.isCollapsed && position == _TextSelectionHandlePosition.end) ||
383
        selectionControls == null)
384
      return new Container(); // hide the second handle when collapsed
385 386 387 388

    return new FadeTransition(
      opacity: _handleOpacity,
      child: new _TextSelectionHandleOverlay(
389
        onSelectionHandleChanged: (TextSelection newSelection) { _handleSelectionHandleChanged(newSelection, position); },
390
        onSelectionHandleTapped: _handleSelectionHandleTapped,
391
        layerLink: layerLink,
392 393
        renderObject: renderObject,
        selection: _selection,
394
        selectionControls: selectionControls,
395
        position: position,
396
      )
397 398 399
    );
  }

400
  Widget _buildToolbar(BuildContext context) {
401
    if (selectionControls == null)
402 403 404
      return new Container();

    // Find the horizontal midpoint, just above the selected text.
405
    final List<TextSelectionPoint> endpoints = renderObject.getEndpointsForSelection(_selection);
406
    final Offset midpoint = new Offset(
407
      (endpoints.length == 1) ?
408 409
        endpoints[0].point.dx :
        (endpoints[0].point.dx + endpoints[1].point.dx) / 2.0,
410
      endpoints[0].point.dy - renderObject.preferredLineHeight,
411 412 413 414 415
    );

    final Rect editingRegion = new Rect.fromPoints(
      renderObject.localToGlobal(Offset.zero),
      renderObject.localToGlobal(renderObject.size.bottomRight(Offset.zero)),
416 417
    );

418 419
    return new FadeTransition(
      opacity: _toolbarOpacity,
420 421 422 423
      child: new CompositedTransformFollower(
        link: layerLink,
        showWhenUnlinked: false,
        offset: -editingRegion.topLeft,
424
        child: selectionControls.buildToolbar(context, editingRegion, midpoint, selectionDelegate),
425
      ),
426
    );
427 428
  }

429
  void _handleSelectionHandleChanged(TextSelection newSelection, _TextSelectionHandlePosition position) {
430
    TextPosition textPosition;
431 432
    switch (position) {
      case _TextSelectionHandlePosition.start:
433
        textPosition = newSelection.base;
434 435
        break;
      case _TextSelectionHandlePosition.end:
436
        textPosition =newSelection.extent;
437 438
        break;
    }
439 440
    selectionDelegate.textEditingValue = _value.copyWith(selection: newSelection, composing: TextRange.empty);
    selectionDelegate.bringIntoView(textPosition);
441 442
  }

443
  void _handleSelectionHandleTapped() {
444
    if (_value.selection.isCollapsed) {
445 446 447 448 449 450 451 452
      if (_toolbar != null) {
        _toolbar?.remove();
        _toolbar = null;
      } else {
        showToolbar();
      }
    }
  }
453 454 455 456
}

/// This widget represents a single draggable text selection handle.
class _TextSelectionHandleOverlay extends StatefulWidget {
457
  const _TextSelectionHandleOverlay({
458
    Key key,
459 460 461 462 463 464 465
    @required this.selection,
    @required this.position,
    @required this.layerLink,
    @required this.renderObject,
    @required this.onSelectionHandleChanged,
    @required this.onSelectionHandleTapped,
    @required this.selectionControls
466 467 468 469
  }) : super(key: key);

  final TextSelection selection;
  final _TextSelectionHandlePosition position;
470
  final LayerLink layerLink;
471
  final RenderEditable renderObject;
472
  final ValueChanged<TextSelection> onSelectionHandleChanged;
473
  final VoidCallback onSelectionHandleTapped;
474
  final TextSelectionControls selectionControls;
475 476 477 478 479 480

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

class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay> {
481
  Offset _dragPosition;
482

483
  void _handleDragStart(DragStartDetails details) {
484
    _dragPosition = details.globalPosition + new Offset(0.0, -widget.selectionControls.handleSize.height);
485 486
  }

487 488
  void _handleDragUpdate(DragUpdateDetails details) {
    _dragPosition += details.delta;
489
    final TextPosition position = widget.renderObject.getPositionForPoint(_dragPosition);
490

491 492
    if (widget.selection.isCollapsed) {
      widget.onSelectionHandleChanged(new TextSelection.fromPosition(position));
493 494 495 496
      return;
    }

    TextSelection newSelection;
497
    switch (widget.position) {
498 499 500
      case _TextSelectionHandlePosition.start:
        newSelection = new TextSelection(
          baseOffset: position.offset,
501
          extentOffset: widget.selection.extentOffset
502 503 504 505
        );
        break;
      case _TextSelectionHandlePosition.end:
        newSelection = new TextSelection(
506
          baseOffset: widget.selection.baseOffset,
507 508 509 510 511 512 513 514
          extentOffset: position.offset
        );
        break;
    }

    if (newSelection.baseOffset >= newSelection.extentOffset)
      return; // don't allow order swapping.

515
    widget.onSelectionHandleChanged(newSelection);
516 517
  }

518
  void _handleTap() {
519
    widget.onSelectionHandleTapped();
520 521
  }

522 523
  @override
  Widget build(BuildContext context) {
524
    final List<TextSelectionPoint> endpoints = widget.renderObject.getEndpointsForSelection(widget.selection);
525
    Offset point;
526 527
    TextSelectionHandleType type;

528
    switch (widget.position) {
529 530 531 532 533 534 535 536 537 538 539 540 541
      case _TextSelectionHandlePosition.start:
        point = endpoints[0].point;
        type = _chooseType(endpoints[0], TextSelectionHandleType.left, TextSelectionHandleType.right);
        break;
      case _TextSelectionHandlePosition.end:
        // [endpoints] will only contain 1 point for collapsed selections, in
        // which case we shouldn't be building the [end] handle.
        assert(endpoints.length == 2);
        point = endpoints[1].point;
        type = _chooseType(endpoints[1], TextSelectionHandleType.right, TextSelectionHandleType.left);
        break;
    }

542 543 544 545 546 547 548 549 550 551 552 553
    return new CompositedTransformFollower(
      link: widget.layerLink,
      showWhenUnlinked: false,
      child: new GestureDetector(
        onPanStart: _handleDragStart,
        onPanUpdate: _handleDragUpdate,
        onTap: _handleTap,
        child: new Stack(
          children: <Widget>[
            new Positioned(
              left: point.dx,
              top: point.dy,
xster's avatar
xster committed
554 555 556
              child: widget.selectionControls.buildHandle(
                context,
                type,
557
                widget.renderObject.preferredLineHeight,
xster's avatar
xster committed
558
              ),
559 560 561 562
            ),
          ],
        ),
      ),
563 564 565 566 567 568 569 570
    );
  }

  TextSelectionHandleType _chooseType(
    TextSelectionPoint endpoint,
    TextSelectionHandleType ltrType,
    TextSelectionHandleType rtlType
  ) {
571
    if (widget.selection.isCollapsed)
572 573
      return TextSelectionHandleType.collapsed;

574
    assert(endpoint.direction != null);
575
    switch (endpoint.direction) {
576 577 578 579 580
      case TextDirection.ltr:
        return ltrType;
      case TextDirection.rtl:
        return rtlType;
    }
pq's avatar
pq committed
581
    return null;
582 583
  }
}