input.dart 17.9 KB
Newer Older
1 2 3 4
// Copyright 2015 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 6
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
7

8
import 'colors.dart';
9
import 'debug.dart';
10
import 'icon.dart';
Ian Hickson's avatar
Ian Hickson committed
11 12
import 'icon_theme.dart';
import 'icon_theme_data.dart';
13
import 'text_selection.dart';
14
import 'theme.dart';
15

16
export 'package:flutter/services.dart' show TextInputType;
17

18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
const Duration _kTransitionDuration = const Duration(milliseconds: 200);
const Curve _kTransitionCurve = Curves.fastOutSlowIn;

/// A simple text input field.
///
/// This widget is comparable to [Text] in that it does not include a margin
/// or any decoration outside the text itself. It is useful for applications,
/// like a search box, that don't need any additional decoration. It should
/// also be useful in custom widgets that support text input.
///
/// The [value] field must be updated each time the [onChanged] callback is
/// invoked. Be sure to include the full [value] provided by the [onChanged]
/// callback, or information like the current selection will be lost.
///
/// Requires one of its ancestors to be a [Material] widget.
///
/// See also:
///
/// * [Input], which adds a label, a divider below the text field, and support for
///   an error message.
38 39 40 41 42 43 44 45 46 47
class InputField extends StatefulWidget {
  InputField({
    Key key,
    this.focusKey,
    this.value,
    this.keyboardType: TextInputType.text,
    this.hintText,
    this.style,
    this.hideText: false,
    this.maxLines: 1,
48
    this.autofocus: false,
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
    this.onChanged,
    this.onSubmitted,
  }) : super(key: key);

  final GlobalKey focusKey;

  /// The current state of text of the input field. This includes the selected
  /// text, if any, among other things.
  final InputValue value;

  /// The type of keyboard to use for editing the text.
  final TextInputType keyboardType;

  /// Text to show inline in the input field when it would otherwise be empty.
  final String hintText;

  /// The style to use for the text being edited.
  final TextStyle style;

  /// Whether to hide the text being edited (e.g., for passwords).
  ///
  /// When this is set to true, all the characters in the input are replaced by
  /// U+2022 BULLET characters (•).
  final bool hideText;

  /// The maximum number of lines for the text to span, wrapping if necessary.
  /// If this is 1 (the default), the text will not wrap, but will scroll
  /// horizontally instead.
  final int maxLines;

79 80 81
  /// Whether this input field should focus itself if nothing else is already focused.
  final bool autofocus;

82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 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
  /// Called when the text being edited changes.
  ///
  /// The [value] must be updated each time [onChanged] is invoked.
  final ValueChanged<InputValue> onChanged;

  /// Called when the user indicates that they are done editing the text in the field.
  final ValueChanged<InputValue> onSubmitted;

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

class _InputFieldState extends State<InputField> {
  GlobalKey<RawInputState> _rawInputKey = new GlobalKey<RawInputState>();
  GlobalKey<RawInputState> _focusKey = new GlobalKey(debugLabel: "_InputFieldState _focusKey");

  GlobalKey get focusKey => config.focusKey ?? (config.key is GlobalKey ? config.key : _focusKey);

  void requestKeyboard() {
    _rawInputKey.currentState?.requestKeyboard();
  }

  @override
  Widget build(BuildContext context) {
    assert(debugCheckHasMaterial(context));
    final InputValue value = config.value ?? InputValue.empty;
    final ThemeData themeData = Theme.of(context);
    final TextStyle textStyle = config.style ?? themeData.textTheme.subhead;

    final List<Widget> stackChildren = <Widget>[
      new GestureDetector(
        key: focusKey == _focusKey ? _focusKey : null,
        behavior: HitTestBehavior.opaque,
        onTap: () {
          requestKeyboard();
        },
        // Since the focusKey may have been created here, defer building the
        // RawInput until the focusKey's context has been set. This is necessary
        // because the RawInput will check the focus, like Focus.at(focusContext),
        // when it builds.
        child: new Builder(
          builder: (BuildContext context) {
            return new RawInput(
              key: _rawInputKey,
              value: value,
              focusKey: focusKey,
              style: textStyle,
              hideText: config.hideText,
              maxLines: config.maxLines,
131
              autofocus: config.autofocus,
132 133 134 135 136 137 138 139 140 141 142 143 144 145
              cursorColor: themeData.textSelectionColor,
              selectionColor: themeData.textSelectionColor,
              selectionControls: materialTextSelectionControls,
              platform: Theme.of(context).platform,
              keyboardType: config.keyboardType,
              onChanged: config.onChanged,
              onSubmitted: config.onSubmitted,
            );
          }
        ),
      ),
    ];

    if (config.hintText != null && value.text.isEmpty) {
146 147

      TextStyle hintStyle = textStyle.copyWith(color: themeData.hintColor);
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162
      stackChildren.add(
        new Positioned(
          left: 0.0,
          top: textStyle.fontSize - hintStyle.fontSize,
          child: new IgnorePointer(
            child: new Text(config.hintText, style: hintStyle),
          ),
        ),
      );
    }

    return new RepaintBoundary(child: new Stack(children: stackChildren));
  }
}

163 164
/// Displays the visual elements of a material design text field around an
/// arbitrary child widget.
165
///
166 167
/// Use InputContainer to create widgets that look and behave like the [Input]
/// widget.
168
///
169
/// Requires one of its ancestors to be a [Material] widget.
170
///
171
/// See also:
172
///
173 174 175
/// * [Input], which combines an [InputContainer] with an [InputField].
class InputContainer extends StatefulWidget {
  InputContainer({
176
    Key key,
177 178
    this.focused: false,
    this.isEmpty: false,
179 180 181 182 183
    this.icon,
    this.labelText,
    this.hintText,
    this.errorText,
    this.style,
184
    this.isDense: false,
185
    this.hideDivider: false,
186
    this.child,
187
  }) : super(key: key);
188

189
  /// An icon to show adjacent to the input field.
Ian Hickson's avatar
Ian Hickson committed
190 191 192 193 194 195 196
  ///
  /// The size and color of the icon is configured automatically using an
  /// [IconTheme] and therefore does not need to be explicitly given in the
  /// icon widget.
  ///
  /// See [Icon], [ImageIcon].
  final Widget icon;
197

198
  /// Text that appears above the child or over it, if isEmpty is true.
199 200
  final String labelText;

201
  /// Text that appears over the child if isEmpty is true and labelText is null.
202 203
  final String hintText;

204 205
  /// Text that appears below the child. If errorText is non-null the divider
  /// that appears below the child is red.
206 207
  final String errorText;

208 209
  /// The style to use for the hint. It's also used for the label when the label
  /// appears over the child.
210
  final TextStyle style;
211

212
  /// Whether the input container is part of a dense form (i.e., uses less vertical space).
213
  final bool isDense;
214

215 216
  /// True if the hint and label should be displayed as if the child had the focus.
  final bool focused;
217

218 219 220
  /// Should the hint and label be displayed as if no value had been input
  /// to the child.
  final bool isEmpty;
221

222 223 224
  /// Hide the divider that appears below the child and above the error text.
  final bool hideDivider;

225
  final Widget child;
226

227
  @override
228
  _InputContainerState createState() => new _InputContainerState();
229
}
Eric Seidel's avatar
Eric Seidel committed
230

231
class _InputContainerState extends State<InputContainer> {
232
  @override
233
  Widget build(BuildContext context) {
234
    assert(debugCheckHasMaterial(context));
235
    ThemeData themeData = Theme.of(context);
236 237
    String errorText = config.errorText;

238
    final TextStyle textStyle = config.style ?? themeData.textTheme.subhead;
239
    Color activeColor = themeData.hintColor;
240
    if (config.focused) {
241
      switch (themeData.brightness) {
242
        case Brightness.dark:
243 244
          activeColor = themeData.accentColor;
          break;
245
        case Brightness.light:
246 247 248 249
          activeColor = themeData.primaryColor;
          break;
      }
    }
250
    double topPadding = config.isDense ? 12.0 : 16.0;
251

252 253
    List<Widget> stackChildren = <Widget>[];

254 255 256 257 258
    // If we're not focused, there's not value, and labelText was provided,
    // then the label appears where the hint would. And we will not show
    // the hintText.
    final bool hasInlineLabel = !config.focused && config.labelText != null && config.isEmpty;

259
    if (config.labelText != null) {
260
      final TextStyle labelStyle = hasInlineLabel ?
261
        textStyle.copyWith(color: themeData.hintColor) :
262
        themeData.textTheme.caption.copyWith(color: activeColor);
263

264
      final double topPaddingIncrement = themeData.textTheme.caption.fontSize + (config.isDense ? 4.0 : 8.0);
265 266 267 268
      double top = topPadding;
      if (hasInlineLabel)
        top += topPaddingIncrement + textStyle.fontSize - labelStyle.fontSize;

269 270 271 272 273 274
      stackChildren.add(
        new AnimatedPositioned(
          left: 0.0,
          top: top,
          duration: _kTransitionDuration,
          curve: _kTransitionCurve,
275
          child: new Text(config.labelText, style: labelStyle),
276 277
        ),
      );
278 279

      topPadding += topPaddingIncrement;
280 281
    }

282 283 284 285 286 287 288 289 290 291 292 293 294
    if (config.hintText != null && config.isEmpty && !hasInlineLabel) {
      TextStyle hintStyle = textStyle.copyWith(color: themeData.hintColor);
      stackChildren.add(
        new Positioned(
          left: 0.0,
          top: topPadding + textStyle.fontSize - hintStyle.fontSize,
          child: new IgnorePointer(
            child: new Text(config.hintText, style: hintStyle),
          ),
        ),
      );
    }

295
    Color borderColor = activeColor;
296
    double bottomPadding = 8.0;
297
    double bottomBorder = config.focused ? 2.0 : 1.0;
298
    double bottomHeight = config.isDense ? 14.0 : 18.0;
299

300
    if (errorText != null) {
301
      borderColor = themeData.errorColor;
302 303 304
      bottomBorder = 2.0;
      if (!config.isDense)
        bottomPadding = 1.0;
305 306
    }

307 308 309 310 311 312 313 314 315
    EdgeInsets padding = new EdgeInsets.only(top: topPadding, bottom: bottomPadding);
    Border border = new Border(
      bottom: new BorderSide(
        color: borderColor,
        width: bottomBorder,
      )
    );
    EdgeInsets margin = new EdgeInsets.only(bottom: bottomHeight - (bottomPadding + bottomBorder));

316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335
    Widget divider;
    if (config.hideDivider) {
      divider = new Container(
        margin: margin + new EdgeInsets.only(bottom: bottomBorder),
        padding: padding,
        child: config.child,
      );
    } else {
      divider = new AnimatedContainer(
        margin: margin,
        padding: padding,
        duration: _kTransitionDuration,
        curve: _kTransitionCurve,
        decoration: new BoxDecoration(
          border: border,
        ),
        child: config.child,
      );
    }
    stackChildren.add(divider);
336

337
    if (errorText != null && !config.isDense) {
338
      TextStyle errorStyle = themeData.textTheme.caption.copyWith(color: themeData.errorColor);
339 340 341
      stackChildren.add(new Positioned(
        left: 0.0,
        bottom: 0.0,
342
        child: new Text(errorText, style: errorStyle)
343 344 345
      ));
    }

346
    Widget textField = new Stack(children: stackChildren);
347 348 349 350

    if (config.icon != null) {
      double iconSize = config.isDense ? 18.0 : 24.0;
      double iconTop = topPadding + (textStyle.fontSize - iconSize) / 2.0;
351
      textField = new Row(
352
        crossAxisAlignment: CrossAxisAlignment.start,
353
        children: <Widget>[
354
          new Container(
355
            margin: new EdgeInsets.only(right: 16.0, top: iconTop),
356
            width: config.isDense ? 40.0 : 48.0,
Ian Hickson's avatar
Ian Hickson committed
357 358 359
            child: new IconTheme.merge(
              context: context,
              data: new IconThemeData(
360
                color: config.focused ? activeColor : Colors.black45,
Ian Hickson's avatar
Ian Hickson committed
361 362 363
                size: config.isDense ? 18.0 : 24.0
              ),
              child: config.icon
364 365
            )
          ),
366
          new Expanded(child: textField)
367 368 369 370
        ]
      );
    }

371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406
    return textField;
  }
}

/// A material design text input field.
///
/// The [value] field must be updated each time the [onChanged] callback is
/// invoked. Be sure to include the full [value] provided by the [onChanged]
/// callback, or information like the current selection will be lost.
///
/// Requires one of its ancestors to be a [Material] widget.
///
/// See also:
///
///  * <https://material.google.com/components/text-fields.html>
///
/// For a detailed guide on using the input widget, see:
///
/// * <https://flutter.io/text-input/>
class Input extends StatefulWidget {
  /// Creates a text input field.
  ///
  /// By default, the input uses a keyboard appropriate for text entry.
  //
  //  If you change this constructor signature, please also update
  // InputContainer, InputFormField, InputField.
  Input({
    Key key,
    this.value,
    this.keyboardType: TextInputType.text,
    this.icon,
    this.labelText,
    this.hintText,
    this.errorText,
    this.style,
    this.hideText: false,
407
    this.hideDivider: false,
408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448
    this.isDense: false,
    this.autofocus: false,
    this.maxLines: 1,
    this.onChanged,
    this.onSubmitted,
  }) : super(key: key);

  /// The current state of text of the input field. This includes the selected
  /// text, if any, among other things.
  final InputValue value;

  /// The type of keyboard to use for editing the text.
  final TextInputType keyboardType;

  /// An icon to show adjacent to the input field.
  ///
  /// The size and color of the icon is configured automatically using an
  /// [IconTheme] and therefore does not need to be explicitly given in the
  /// icon widget.
  ///
  /// See [Icon], [ImageIcon].
  final Widget icon;

  /// Text to show above the input field.
  final String labelText;

  /// Text to show inline in the input field when it would otherwise be empty.
  final String hintText;

  /// Text to show when the input text is invalid.
  final String errorText;

  /// The style to use for the text being edited.
  final TextStyle style;

  /// Whether to hide the text being edited (e.g., for passwords).
  ///
  /// When this is set to true, all the characters in the input are replaced by
  /// U+2022 BULLET characters (•).
  final bool hideText;

449 450 451
  /// Hide the divider that appears below the child and above the error text.
  final bool hideDivider;

452 453 454 455
  /// Whether the input field is part of a dense form (i.e., uses less vertical space).
  final bool isDense;

  /// Whether this input field should focus itself if nothing else is already focused.
456 457 458 459
  /// If true, the keyboard will open as soon as this input obtains focus. Otherwise,
  /// the keyboard is only shown after the user taps the text field.
  // See https://github.com/flutter/flutter/issues/7035 for the rationale for this
  // keyboard behavior.
460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486
  final bool autofocus;

  /// The maximum number of lines for the text to span, wrapping if necessary.
  /// If this is 1 (the default), the text will not wrap, but will scroll
  /// horizontally instead.
  final int maxLines;

  /// Called when the text being edited changes.
  ///
  /// The [value] must be updated each time [onChanged] is invoked.
  final ValueChanged<InputValue> onChanged;

  /// Called when the user indicates that they are done editing the text in the field.
  final ValueChanged<InputValue> onSubmitted;

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

class _InputState extends State<Input> {
  final GlobalKey<_InputFieldState> _inputFieldKey = new GlobalKey<_InputFieldState>();
  final GlobalKey _focusKey = new GlobalKey();

  GlobalKey get focusKey => config.key is GlobalKey ? config.key : _focusKey;

  @override
  Widget build(BuildContext context) {
487
    return new GestureDetector(
488
      key: focusKey == _focusKey ? _focusKey : null,
489 490 491
      onTap: () {
        _inputFieldKey.currentState?.requestKeyboard();
      },
492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507
      // Since the focusKey may have been created here, defer building the
      // InputContainer until the focusKey's context has been set. This is
      // necessary because we're passing the value of Focus.at() along.
      child: new Builder(
        builder: (BuildContext context) {
          final bool focused = Focus.at(focusKey.currentContext, autofocus: config.autofocus);
          final bool isEmpty = (config.value ?? InputValue.empty).text.isEmpty;
          return new InputContainer(
            focused: focused,
            isEmpty: isEmpty,
            icon: config.icon,
            labelText: config.labelText,
            hintText: config.hintText,
            errorText: config.errorText,
            style: config.style,
            isDense: config.isDense,
508
            hideDivider: config.hideDivider,
509 510 511 512 513 514 515
            child: new InputField(
              key: _inputFieldKey,
              focusKey: focusKey,
              value: config.value,
              style: config.style,
              hideText: config.hideText,
              maxLines: config.maxLines,
516
              autofocus: config.autofocus,
517 518 519 520 521 522 523
              keyboardType: config.keyboardType,
              onChanged: config.onChanged,
              onSubmitted: config.onSubmitted,
            ),
          );
        },
      ),
524 525 526
    );
  }
}
527

Matt Perry's avatar
Matt Perry committed
528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567
/// A [FormField] that contains an [Input].
class InputFormField extends FormField<InputValue> {
  InputFormField({
    Key key,
    GlobalKey focusKey,
    TextInputType keyboardType: TextInputType.text,
    Icon icon,
    String labelText,
    String hintText,
    TextStyle style,
    bool hideText: false,
    bool isDense: false,
    bool autofocus: false,
    int maxLines: 1,
    InputValue initialValue: InputValue.empty,
    FormFieldSetter<InputValue> onSaved,
    FormFieldValidator<InputValue> validator,
  }) : super(
    key: key,
    initialValue: initialValue,
    onSaved: onSaved,
    validator: validator,
    builder: (FormFieldState<InputValue> field) {
      return new Input(
        key: focusKey,
        keyboardType: keyboardType,
        icon: icon,
        labelText: labelText,
        hintText: hintText,
        style: style,
        hideText: hideText,
        isDense: isDense,
        autofocus: autofocus,
        maxLines: maxLines,
        value: field.value,
        onChanged: field.onChanged,
        errorText: field.errorText,
      );
    },
  );
568
}