androidflutterdartuser-interfacetextfield

Flutter TextField, need to reposition the TextField label and solve label being always shown to focused position due to prefix


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

Solution

  • 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,
                  ),