text_selection.dart 13.6 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.

5
import 'package:flutter/foundation.dart';
6
import 'package:flutter/rendering.dart';
7
import 'package:flutter/scheduler.dart';
8 9

import 'basic.dart';
10
import 'container.dart';
11
import 'editable_text.dart';
12 13 14
import 'framework.dart';
import 'gesture_detector.dart';
import 'overlay.dart';
15
import 'transitions.dart';
16

17 18 19 20 21
/// Which type of selection handle to be displayed.
///
/// With mixed-direction text, both handles may be the same type. Examples:
///
/// * LTR text: 'the <quick brown> fox':
22
///   The '<' is drawn with the [left] type, the '>' with the [right]
23 24
///
/// * RTL text: 'xof <nworb kciuq> eht':
25
///   Same as above.
26 27
///
/// * mixed text: '<the nwor<b quick fox'
28 29
///   Here 'the b' is selected, but 'brown' is RTL. Both are drawn with the
///   [left] type.
30 31 32
enum TextSelectionHandleType {
  /// The selection handle is to the left of the selection end point.
  left,
33

34 35 36 37 38 39 40
  /// 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,
}

41 42 43 44
/// 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 }

45 46 47 48 49 50
/// Signature for reporting changes to the selection component of an
/// [InputValue] 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].
///
/// Used by [TextSelectionOverlay.onSelectionOverlayChanged].
51 52
typedef void TextSelectionOverlayChanged(InputValue value, Rect caretRect);

53 54 55 56 57 58 59
/// An interface for manipulating the selection, to be used by the implementor
/// of the toolbar widget.
abstract class TextSelectionDelegate {
  /// Gets the current text input.
  InputValue get inputValue;

  /// Sets the current text input (replaces the whole line).
60
  set inputValue(InputValue value);
61 62 63 64 65

  /// Hides the text selection toolbar.
  void hideToolbar();
}

66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
// An interface for building the selection UI, to be provided by the
// implementor of the toolbar widget.
abstract class TextSelectionControls {
  /// Builds a selection handle of the given type.
  Widget buildHandle(BuildContext context, TextSelectionHandleType type);

  /// Builds a toolbar near a text selection.
  ///
  /// Typically displays buttons for copying and pasting text.
  // TODO(mpcomplete): A single position is probably insufficient.
  Widget buildToolbar(BuildContext context, Point position, TextSelectionDelegate delegate);

  /// Returns the size of the selection handle.
  Size get handleSize;
}

82 83 84 85
/// 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].
86
class TextSelectionOverlay implements TextSelectionDelegate {
87 88 89
  /// Creates an object that manages overly entries for selection handles.
  ///
  /// The [context] must not be null and must have an [Overlay] as an ancestor.
90 91
  TextSelectionOverlay({
    InputValue input,
92
    @required this.context,
93
    this.debugRequiredFor,
94
    this.renderObject,
95
    this.onSelectionOverlayChanged,
96
    this.selectionControls,
97 98
  }): _input = input {
    assert(context != null);
99 100 101 102
    final OverlayState overlay = Overlay.of(context);
    assert(overlay != null);
    _handleController = new AnimationController(duration: _kFadeDuration, vsync: overlay);
    _toolbarController = new AnimationController(duration: _kFadeDuration, vsync: overlay);
103
  }
104

105 106 107 108
  /// 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].
109
  final BuildContext context;
110 111

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

114 115
  // 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.
116
  /// The editable line in which the selected text is being displayed.
117
  final RenderEditable renderObject;
118 119 120 121 122

  /// Called when the the selection changes.
  ///
  /// For example, if the use drags one of the selection handles, this function
  /// will be called with a new input value with an updated selection.
123
  final TextSelectionOverlayChanged onSelectionOverlayChanged;
124

125 126
  /// Builds text selection handles and toolbar.
  final TextSelectionControls selectionControls;
127

128 129
  /// Controls the fade-in animations.
  static const Duration _kFadeDuration = const Duration(milliseconds: 150);
130 131
  AnimationController _handleController;
  AnimationController _toolbarController;
132 133 134
  Animation<double> get _handleOpacity => _handleController.view;
  Animation<double> get _toolbarOpacity => _toolbarController.view;

135
  InputValue _input;
136 137 138 139 140

  /// 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;

141
  /// A copy/paste toolbar.
142 143 144 145
  OverlayEntry _toolbar;

  TextSelection get _selection => _input.selection;

146
  /// Shows the handles by inserting them into the [context]'s overlay.
147
  void showHandles() {
148 149
    assert(_handles == null);
    _handles = <OverlayEntry>[
150 151
      new OverlayEntry(builder: (BuildContext c) => _buildHandle(c, _TextSelectionHandlePosition.start)),
      new OverlayEntry(builder: (BuildContext c) => _buildHandle(c, _TextSelectionHandlePosition.end)),
152 153
    ];
    Overlay.of(context, debugRequiredFor: debugRequiredFor).insertAll(_handles);
154
    _handleController.forward(from: 0.0);
155 156 157 158 159 160
  }

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

165
  /// Updates the overlay after the [selection] has changed.
166 167 168 169 170 171 172 173
  ///
  /// If this method is called while the [SchedulerBinding.schedulerPhase] is
  /// [SchedulerBinding.persistentCallbacks], i.e. during the build, layout, or
  /// paint phases (see [WidgetsBinding.beginFrame]), then the update is delayed
  /// 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).
174
  void update(InputValue newInput) {
175
    if (_input == newInput)
176
      return;
177
    _input = newInput;
178 179 180 181 182 183 184 185
    if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) {
      SchedulerBinding.instance.addPostFrameCallback(_markNeedsBuild);
    } else {
      _markNeedsBuild();
    }
  }

  void _markNeedsBuild([Duration duration]) {
186 187 188 189 190
    if (_handles != null) {
      _handles[0].markNeedsBuild();
      _handles[1].markNeedsBuild();
    }
    _toolbar?.markNeedsBuild();
191 192
  }

193
  /// Hides the overlay.
194
  void hide() {
195 196 197 198 199 200
    if (_handles != null) {
      _handles[0].remove();
      _handles[1].remove();
      _handles = null;
    }
    _toolbar?.remove();
201
    _toolbar = null;
202 203 204 205 206 207 208 209 210 211

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

  /// Final cleanup.
  void dispose() {
    hide();
    _handleController.dispose();
    _toolbarController.dispose();
212 213
  }

214
  Widget _buildHandle(BuildContext context, _TextSelectionHandlePosition position) {
215
    if ((_selection.isCollapsed && position == _TextSelectionHandlePosition.end) ||
216
        selectionControls == null)
217
      return new Container();  // hide the second handle when collapsed
218 219 220 221

    return new FadeTransition(
      opacity: _handleOpacity,
      child: new _TextSelectionHandleOverlay(
222
        onSelectionHandleChanged: (TextSelection newSelection) { _handleSelectionHandleChanged(newSelection, position); },
223 224 225
        onSelectionHandleTapped: _handleSelectionHandleTapped,
        renderObject: renderObject,
        selection: _selection,
226
        selectionControls: selectionControls,
227 228
        position: position
      )
229 230 231
    );
  }

232
  Widget _buildToolbar(BuildContext context) {
233
    if (selectionControls == null)
234 235 236
      return new Container();

    // Find the horizontal midpoint, just above the selected text.
237 238
    final List<TextSelectionPoint> endpoints = renderObject.getEndpointsForSelection(_selection);
    final Point midpoint = new Point(
239 240 241 242 243 244
      (endpoints.length == 1) ?
        endpoints[0].point.x :
        (endpoints[0].point.x + endpoints[1].point.x) / 2.0,
      endpoints[0].point.y - renderObject.size.height
    );

245 246
    return new FadeTransition(
      opacity: _toolbarOpacity,
247
      child: selectionControls.buildToolbar(context, midpoint, this)
248
    );
249 250
  }

251 252 253 254 255 256 257 258 259 260 261 262 263
  void _handleSelectionHandleChanged(TextSelection newSelection, _TextSelectionHandlePosition position) {
    Rect caretRect;
    switch (position) {
      case _TextSelectionHandlePosition.start:
        caretRect = renderObject.getLocalRectForCaret(newSelection.base);
        break;
      case _TextSelectionHandlePosition.end:
        caretRect = renderObject.getLocalRectForCaret(newSelection.extent);
        break;
    }
    update(_input.copyWith(selection: newSelection, composing: TextRange.empty));
    if (onSelectionOverlayChanged != null)
      onSelectionOverlayChanged(_input, caretRect);
264 265
  }

266 267 268 269 270 271 272 273 274 275 276
  void _handleSelectionHandleTapped() {
    if (inputValue.selection.isCollapsed) {
      if (_toolbar != null) {
        _toolbar?.remove();
        _toolbar = null;
      } else {
        showToolbar();
      }
    }
  }

277 278 279 280
  @override
  InputValue get inputValue => _input;

  @override
281
  set inputValue(InputValue value) {
282
    update(value);
283
    if (onSelectionOverlayChanged != null) {
284
      final Rect caretRect = renderObject.getLocalRectForCaret(value.selection.extent);
285 286
      onSelectionOverlayChanged(value, caretRect);
    }
287 288 289 290 291
  }

  @override
  void hideToolbar() {
    hide();
292 293 294 295 296 297 298 299 300 301 302
  }
}

/// This widget represents a single draggable text selection handle.
class _TextSelectionHandleOverlay extends StatefulWidget {
  _TextSelectionHandleOverlay({
    Key key,
    this.selection,
    this.position,
    this.renderObject,
    this.onSelectionHandleChanged,
303
    this.onSelectionHandleTapped,
304
    this.selectionControls
305 306 307 308
  }) : super(key: key);

  final TextSelection selection;
  final _TextSelectionHandlePosition position;
309
  final RenderEditable renderObject;
310
  final ValueChanged<TextSelection> onSelectionHandleChanged;
311
  final VoidCallback onSelectionHandleTapped;
312
  final TextSelectionControls selectionControls;
313 314 315 316 317 318 319

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

class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay> {
  Point _dragPosition;
320

321
  void _handleDragStart(DragStartDetails details) {
322
    _dragPosition = details.globalPosition + new Offset(0.0, -config.selectionControls.handleSize.height);
323 324
  }

325 326
  void _handleDragUpdate(DragUpdateDetails details) {
    _dragPosition += details.delta;
327
    final TextPosition position = config.renderObject.getPositionForPoint(_dragPosition);
328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355

    if (config.selection.isCollapsed) {
      config.onSelectionHandleChanged(new TextSelection.fromPosition(position));
      return;
    }

    TextSelection newSelection;
    switch (config.position) {
      case _TextSelectionHandlePosition.start:
        newSelection = new TextSelection(
          baseOffset: position.offset,
          extentOffset: config.selection.extentOffset
        );
        break;
      case _TextSelectionHandlePosition.end:
        newSelection = new TextSelection(
          baseOffset: config.selection.baseOffset,
          extentOffset: position.offset
        );
        break;
    }

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

    config.onSelectionHandleChanged(newSelection);
  }

356 357 358 359
  void _handleTap() {
    config.onSelectionHandleTapped();
  }

360 361
  @override
  Widget build(BuildContext context) {
362
    final List<TextSelectionPoint> endpoints = config.renderObject.getEndpointsForSelection(config.selection);
363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380
    Point point;
    TextSelectionHandleType type;

    switch (config.position) {
      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;
    }

    return new GestureDetector(
381 382
      onPanStart: _handleDragStart,
      onPanUpdate: _handleDragUpdate,
383
      onTap: _handleTap,
384 385 386 387 388
      child: new Stack(
        children: <Widget>[
          new Positioned(
            left: point.x,
            top: point.y,
389
            child: config.selectionControls.buildHandle(context, type)
390 391 392 393 394 395 396 397 398 399 400
          )
        ]
      )
    );
  }

  TextSelectionHandleType _chooseType(
    TextSelectionPoint endpoint,
    TextSelectionHandleType ltrType,
    TextSelectionHandleType rtlType
  ) {
401 402 403
    if (config.selection.isCollapsed)
      return TextSelectionHandleType.collapsed;

404
    assert(endpoint.direction != null);
405
    switch (endpoint.direction) {
406 407 408 409 410
      case TextDirection.ltr:
        return ltrType;
      case TextDirection.rtl:
        return rtlType;
    }
pq's avatar
pq committed
411
    return null;
412 413
  }
}