2

I'm trying to build a pixel-perfect design in Flutter that includes a TextField. The design requires the label to behave like this:

  1. When the TextField is not focused and empty, the label should appear inside the input field as a placeholder.
  2. When the TextField is focused, the label should move just above the input text but still inside the border, not at the top-left edge of the TextField (the default floating label position).

I need: I need to design a pixel-perfect UI according to the design below:

Required Target Design

But I'm currently having this:

I'm having this currently

The Problem: I have a prefix widget (like a country code selector for a phone number) that is always visible. Because of this, the label behaves as if the TextField is always focused — it's always displayed in its floating position, even when the field is not focused or filled.

Current Code What I've tried: Here is the code that I've tried to get the target design.

TextField(
        keyboardType: TextInputType.phone,
        scrollPadding: EdgeInsets.zero,
        decoration: InputDecoration(
          floatingLabelBehavior: FloatingLabelBehavior.always,
          label: Text(
            'Phone number',
            style: TextStyle(
              color: AppColors.secondaryTextColor,
              fontSize: 12,
              fontWeight: FontWeight.w500,
            ),
          ),
          errorText: showError ? formState['phoneError'] : null,
          errorMaxLines: 2,
          errorStyle: const TextStyle(fontSize: 12),

          // Content Padding
          contentPadding:
              const EdgeInsets.symmetric(vertical: 16, horizontal: 12),

          // Prefix for country code
          prefix: IntrinsicHeight(
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                CountryCodePicker(
                  onChanged: (country) {
                    formNotifier.updateCountryCode(
                        country.dialCode ?? '+91', country.name ?? 'India');
                  },
                  textStyle: const TextStyle(
                    fontSize: 16,
                    color: AppColors.primaryTextColor,
                    fontWeight: FontWeight.w600,
                  ),
                  initialSelection: formState['countryCode'],
                  favorite: const ['IN', 'BD', 'US'],
                  showFlag: false,
                  showDropDownButton: true,
                  alignLeft: false,
                  padding: EdgeInsets.zero,
                  flagWidth: 24,
                  builder: (country) => Row(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      Text(
                        country?.dialCode ?? '+91',
                        style: const TextStyle(
                          fontSize: 16,
                          fontWeight: FontWeight.w600,
                        ),
                      ),
                      Gap(8),
                    ],
                  ),
                ),
                const VerticalDivider(
                  thickness: 1,
                  width: 1,
                  color: AppColors.defaultBorderColor,
                ),
                const SizedBox(width: 8),
              ],
            ),
          ),

          // Suffix Icons for validation
          suffixIcon: showError
              ? const Icon(Icons.close, color: Colors.red)
              : isValid
                  ? const Icon(Icons.check, color: Colors.green)
                  : null,

          // Borders
          border: OutlineInputBorder(
            borderRadius: BorderRadius.circular(8),
            borderSide:
                const BorderSide(color: AppColors.defaultBorderColor),
          ),
          focusedBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(8),
            borderSide: const BorderSide(
                color: AppColors.darkBackgroundColor, width: 2),
          ),
          enabledBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(8),
            borderSide:
                const BorderSide(color: AppColors.defaultBorderColor),
          ),
          errorBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(8),
            borderSide: const BorderSide(color: Colors.red, width: 1.5),
          ),
          focusedErrorBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(8),
            borderSide: const BorderSide(color: Colors.red, width: 2),
          ),
        ),
        onChanged: (value) => formNotifier.updateField('phone', value),
      )
1
  • based on the UI you want to make i don't think the textfield is capable of that. I suggest take a row and add country code selector widget and then in column add text for a label and a textfield without any borders. Commented Jun 15 at 13:48

2 Answers 2

2

Looking at the UI you want to achieve, I don't think that is possible using the native Flutter TextField.

Try building a custom text field using Stack and manage the label manually with AnimatedPositioned.

Here is a simple reusable component you can expand on:

import 'package:flutter/material.dart';

class PhoneInputField extends StatefulWidget {
  final TextEditingController controller;
  final FocusNode? focusNode;
  final String label;
  final String countryCode;
  final bool showError;
  final bool isValid;
  final String? errorMessage;
  final ValueChanged<String>? onChanged;

  const PhoneInputField({
    super.key,
    required this.controller,
    this.focusNode,
    this.label = "Phone number",
    this.countryCode = "+1",
    this.showError = false,
    this.isValid = false,
    this.errorMessage,
    this.onChanged,
  });

  @override
  State<PhoneInputField> createState() => _PhoneInputFieldState();
}

class _PhoneInputFieldState extends State<PhoneInputField> {
  late final FocusNode _internalFocusNode;
  FocusNode get _focusNode => widget.focusNode ?? _internalFocusNode;

  bool get _hasText => widget.controller.text.trim().isNotEmpty;
  bool get _isFocused => _focusNode.hasFocus;

  @override
  void initState() {
    super.initState();
    _internalFocusNode = FocusNode();
    widget.controller.addListener(_refresh);
    _focusNode.addListener(_refresh);
  }

  void _refresh() => setState(() {});

  @override
  void dispose() {
    if (widget.focusNode == null) _internalFocusNode.dispose();
    widget.controller.removeListener(_refresh);
    _focusNode.removeListener(_refresh);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final borderColor = widget.showError
        ? Colors.red
        : _isFocused
        ? Colors.black
        : Colors.grey[400]!;

    final showIcon = !_hasText
        ? null
        : widget.showError
        ? Icons.close
        : widget.isValid
        ? Icons.check
        : null;

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Stack(
          children: [
            Container(
              height: 56,
              decoration: BoxDecoration(
                border: Border.all(color: borderColor, width: 1.5),
                borderRadius: BorderRadius.circular(8),
              ),
              padding: const EdgeInsets.symmetric(horizontal: 12),
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  // replace with your own code dropdwon or text
                  Text(
                    widget.countryCode,
                    style: const TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.w600,
                    ),
                  ),
                  //////
                  const VerticalDivider(width: 20, thickness: 1),
                  Expanded(
                    child: TextField(
                      controller: widget.controller,
                      focusNode: _focusNode,
                      keyboardType: TextInputType.phone,
                      decoration: const InputDecoration(
                        border: InputBorder.none,
                        isDense: true,
                        contentPadding: EdgeInsets.only(top: 18),
                      ),
                      style: const TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.w500,
                      ),
                      onChanged: widget.onChanged,
                    ),
                  ),
                  if (showIcon != null)
                    Icon(
                      showIcon,
                      color: showIcon == Icons.check
                          ? Colors.green
                          : Colors.red,
                    ),
                ],
              ),
            ),

            AnimatedPositioned(
              duration: const Duration(milliseconds: 200),
              left: 66,
              top: _isFocused || _hasText ? 8 : 18,
              child: AnimatedDefaultTextStyle(
                duration: const Duration(milliseconds: 200),
                style: TextStyle(
                  fontSize: _isFocused || _hasText ? 12 : 16,
                  color: widget.showError
                      ? Colors.red
                      : _isFocused
                      ? Colors.black
                      : Colors.grey[600],
                  fontWeight: FontWeight.w500,
                ),
                child: Text(widget.label),
              ),
            ),
          ],
        ),
        const SizedBox(height: 6),
        if (widget.showError && widget.errorMessage != null)
          Text(
            widget.errorMessage!,
            style: const TextStyle(color: Colors.red, fontSize: 12),
          ),
      ],
    );
  }
}

And here is how you can use it in your form (checkout, profile, etc.):

PhoneInputField(
                controller: _controller,
                focusNode: _focusNode,
                countryCode: "+1",
                isValid: _isValid,
                showError: _showError,
                errorMessage: "enter valid phone number",
                onChanged: _validate,
              ),
Sign up to request clarification or add additional context in comments.

Comments

0

you can control the focus of the TextField by initializing a FocusNode var before the build method and then assign it to the property focusNode

FocusNode focusNode = FocusNode();

  @override
  void dispose() {
    focusNode.dispose();
    super.dispose();
  }

and then the TextField

TextField(
  //code...
  focusNode: focusNode,
  //code...
)

you can control it like that focusNode.unfocus(); inside any of the onSubmitted or whatever

this solves the focus problem, but what you want, I don't really know how to do that, but I suggest that you don't get stuck with that design for too long, to save time for more important things, if you didn't find a solution.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.