Rogapp
Создание интерфейса чата в .NET MAUI с динамическим вводом и корректной работой клавиатуры
В этой статье расскажем, как создать современный и удобный UI для чата на .NET MAUI, с учетом всех особенностей поведения клавиатуры, форматирования сообщений и обработки ввода.

Особенности реализации

✅ Отличия от стандартных решений:
  • Корректная работа клавиатуры на Android (через MainActivity, а не AndroidManifest!)
  • Удобная верстка сообщений с выравниванием и разным стилем
  • Ограничение высоты поля ввода без ручных расчётов
  • Имитация задержки ответа от бота
  • Отсутствие подчеркиваний у Entry и Editor
📦 Структура проекта
  • MainPage.xaml + MainPage.xaml.cs — UI и логика страницы
  • ChatViewModel.cs — логика отправки/ответа
  • MessageModel.cs — модель одного сообщения
  • Converters/* — конвертеры привязок
  • Behaviors/RemoveUnderlineBehavior.cs — поведение для скрытия подчеркивания
  • App.xaml — ресурсы, стили, поведение
  • MainActivity.cs — фиксы для Android
🧠 ViewModel: логика отправки
using ChatApp.Models;
using System.Collections.ObjectModel;
using System.ComponentModel;

namespace ChatApp.ViewModel
{
    public class ChatViewModel : INotifyPropertyChanged
    {
        public ObservableCollection<MessageModel> Messages { get; set; } = new();
        public event Action ScrollToEndRequested; // ✅ Добавлено

        public async Task SendMessageAsync(string userInput)
        {
            if (string.IsNullOrWhiteSpace(userInput)) return;

            // 1. Сразу добавляем пользовательское сообщение
            Messages.Add(new MessageModel
            {
                Text = userInput,
                IsUser = true,
                SenderName = "Вы",
                Timestamp = DateTime.Now
            });

            ScrollToEndRequested?.Invoke();

            // 2. Ждём 2 секунды (задержка ответа бота)
            await Task.Delay(2000);

            // 3. Добавляем сообщение от бота
            Messages.Add(new MessageModel
            {
                Text = await GetGptResponseAsync(userInput),
                IsUser = false,
                SenderName = "ChatGPT",
                Timestamp = DateTime.Now
            });

            ScrollToEndRequested?.Invoke();
        }




        private async Task<string> GetGptResponseAsync(string prompt)
        {
            // Здесь подключается OpenAI SDK или HttpClient
            await Task.Delay(500); // эмуляция запроса
            return $"🤖 {prompt} — понял!";
        }

        public event PropertyChangedEventHandler PropertyChanged;
    }

}
📱 XAML: отображение сообщений
В основе — CollectionView с двумя Grid, разными стилями и колонками:
  • UserGridStyle: ColumnDefinitions="Auto,*"
  • BotGridStyle: ColumnDefinitions="*,Auto"
В Border применяются разные скругления углов через BoolToCornerRadiusConverter:
using System.Globalization;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Graphics;

namespace ChatApp.Converters
{
    public class BoolToCornerRadiusConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            bool isUser = (bool)value;

            // CornerRadius: TopLeft, TopRight, BottomRight, BottomLeft
            if (isUser)
                return new CornerRadius(20, 20, 20, 0); // правая сторона (пользователь)
            else
                return new CornerRadius(20, 20, 0, 20); // левая сторона (бот)
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
            => throw new NotImplementedException();
    }
}
🧾 Удаление подчёркиваний у Editor
using Microsoft.Maui.Controls;

namespace ChatApp.Behaviors
{
    public class RemoveUnderlineBehavior : Behavior<VisualElement>
    {
        protected override void OnAttachedTo(VisualElement bindable)
        {
            base.OnAttachedTo(bindable);
            ApplyRemoveUnderlineStyle(bindable);
        }

        protected override void OnDetachingFrom(VisualElement bindable)
        {
            base.OnDetachingFrom(bindable);
        }

        private void ApplyRemoveUnderlineStyle(VisualElement element)
        {
            // Установка стиля для Android
#if ANDROID
            element.HandlerChanged += (s, e) =>
            {
                var platformView = element.Handler?.PlatformView;
                if (platformView is Android.Widget.EditText editText)
                {
                    editText.Background = null;
                }

                else if (platformView is Android.Widget.DatePicker datePicker)
                {
                    Android.Graphics.Drawables.LayerDrawable layerDrawable = new Android.Graphics.Drawables.LayerDrawable(new Android.Graphics.Drawables.Drawable[] { });
                    datePicker.Background = layerDrawable; // Удаление подчеркивания у DatePicker
                }
            };
#endif

            // Установка стиля для iOS
#if IOS
            element.HandlerChanged += (s, e) =>
            {
                if (element.Handler?.PlatformView is UIKit.UITextField textField)
                {
                    textField.BorderStyle = UIKit.UITextBorderStyle.None;
                }
                else if (element.Handler?.PlatformView is UIKit.UITextView textView)
                {
                    textView.TextContainer.LineFragmentPadding = 0;
                    textView.TextContainerInset = UIKit.UIEdgeInsets.Zero;
                }
            };
#endif
        }
    }
}
⚠️ Клавиатура Android: правильное решение
Ранее часто советуют писать в AndroidManifest.xml, но это не работает в MAUI. Нужно указать в MainActivity.cs:
using Android.App;
using Android.Content.PM;
using Android.OS;
using Android.Views;

namespace ChatApp
{
    [Activity(Theme = "@style/Maui.SplashTheme",
        MainLauncher = true,
        LaunchMode = LaunchMode.SingleTop,
         WindowSoftInputMode = SoftInput.AdjustResize,
        ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
    public class MainActivity : MauiAppCompatActivity
    {
        protected override void OnCreate(Bundle? savedInstanceState)
        {
            base.OnCreate(savedInstanceState);
            Window.SetSoftInputMode(Android.Views.SoftInput.AdjustResize);
        }
    }
}
📝 Поле ввода: динамическая высота
  • Автоматическая подстройка по содержимому
  • Не нужно вручную рассчитывать высоту!
  • Border оборачивает Editor и растягивается вместе с ним автоматически
<Editor
    x:Name="UserInput"
    AutoSize="TextChanges"
    MaximumHeightRequest="150"
    Placeholder="Введите сообщение..." />
Весь файл
<ContentPage
    x:Class="ChatApp.MainPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    BackgroundColor="white">

    <Grid RowDefinitions="Auto, *, Auto">
        <VerticalStackLayout
            Grid.Row="0"
            Padding="0,10"
            BackgroundColor="White">
            <Label
                Margin="6"
                FontAttributes="Bold"
                FontSize="Title"
                Text="Chat"
                VerticalOptions="Center" />
            <BoxView HeightRequest="1" Color="LightGray" />
        </VerticalStackLayout>
        <!--  История сообщений  -->
        <CollectionView
            x:Name="MessagesView"
            Grid.Row="1"
            Margin="6,0"
            BackgroundColor="White"
            ItemsSource="{Binding Messages}"
            VerticalOptions="Fill">
            <CollectionView.ItemTemplate>
                <DataTemplate>
                    <!--  Если сообщение от пользователя, оно будет в правом столбце, иначе в левом  -->
                    <Grid>
                        <Grid.Resources>
                            <Style x:Key="UserGridStyle" TargetType="Grid">
                                <Setter Property="ColumnDefinitions" Value="Auto,*" />
                            </Style>
                            <Style x:Key="BotGridStyle" TargetType="Grid">
                                <Setter Property="ColumnDefinitions" Value="*,Auto" />
                            </Style>
                        </Grid.Resources>
                        <Grid IsVisible="{Binding IsUser}" Style="{StaticResource UserGridStyle}">
                            <!--  Время  -->
                            <Label
                                Grid.Column="{Binding IsUser, Converter={StaticResource ColumnConverter}, ConverterParameter=time}"
                                Margin="10,0"
                                FontSize="10"
                                HorizontalOptions="Start"
                                Text="{Binding FormattedTime}"
                                TextColor="Gray"
                                VerticalOptions="Center" />

                            <!--  Сообщение  -->
                            <Border
                                Grid.Column="{Binding IsUser, Converter={StaticResource ColumnConverter}, ConverterParameter=message}"
                                Margin="4"
                                Padding="10,6"
                                BackgroundColor="{Binding IsUser, Converter={StaticResource BoolToColorConverter}}"
                                HorizontalOptions="{Binding IsUser, Converter={StaticResource MessageAlignmentConverter}}"
                                Stroke="Transparent">
                                <Border.StrokeShape>
                                    <RoundRectangle CornerRadius="{Binding IsUser, Converter={StaticResource CornerConverter}}" />
                                </Border.StrokeShape>

                                <StackLayout Margin="10,0">
                                    <Label
                                        FontAttributes="Bold"
                                        FontSize="12"
                                        Text="{Binding SenderName}"
                                        TextColor="BlueViolet" />
                                    <Label
                                        FontSize="16"
                                        LineBreakMode="WordWrap"
                                        Text="{Binding Text}" />
                                </StackLayout>
                            </Border>
                        </Grid>

                        <!--  Grid для BOT  -->
                        <Grid IsVisible="{Binding IsUser, Converter={StaticResource InverseBoolConverter}}" Style="{StaticResource BotGridStyle}">

                            <Label
                                Grid.Column="{Binding IsUser, Converter={StaticResource ColumnConverter}, ConverterParameter=time}"
                                Margin="10,0"
                                FontSize="10"
                                HorizontalOptions="Start"
                                Text="{Binding FormattedTime}"
                                TextColor="Gray"
                                VerticalOptions="Center" />

                            <!--  Сообщение  -->
                            <Border
                                Grid.Column="{Binding IsUser, Converter={StaticResource ColumnConverter}, ConverterParameter=message}"
                                Margin="4"
                                Padding="10,6"
                                BackgroundColor="{Binding IsUser, Converter={StaticResource BoolToColorConverter}}"
                                HorizontalOptions="{Binding IsUser, Converter={StaticResource MessageAlignmentConverter}}"
                                Stroke="Transparent">
                                <Border.StrokeShape>
                                    <RoundRectangle CornerRadius="{Binding IsUser, Converter={StaticResource CornerConverter}}" />
                                </Border.StrokeShape>

                                <StackLayout Margin="10,0">
                                    <Label
                                        FontAttributes="Bold"
                                        FontSize="12"
                                        Text="{Binding SenderName}"
                                        TextColor="BlueViolet" />
                                    <Label
                                        FontSize="16"
                                        LineBreakMode="WordWrap"
                                        Text="{Binding Text}" />
                                </StackLayout>
                            </Border>
                        </Grid>
                    </Grid>

                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>

        <!--  Ввод  -->
        <VerticalStackLayout
            Grid.Row="2"
            BackgroundColor="white"
            Spacing="6">
            <BoxView HeightRequest="1" Color="LightGray" />
            <Border
                x:Name="InputBorder"
                Margin="6"
                Background="White"
                Stroke="LightGray"
                VerticalOptions="Start">
                <Border.StrokeShape>
                    <RoundRectangle CornerRadius="20,20,20,20" />
                </Border.StrokeShape>
                <Grid Padding="10" ColumnDefinitions="*, Auto">
                    <Editor
                        x:Name="UserInput"
                        AutoSize="TextChanges"
                        BackgroundColor="White"
                        FontSize="16"
                        MaximumHeightRequest="150"
                        Placeholder="Введите сообщение..."
                        TextColor="Black" />
                    <ImageButton
                        Grid.Column="1"
                        Clicked="OnSendClicked"
                        HeightRequest="40"
                        Source="send.png"
                        VerticalOptions="End"
                        WidthRequest="40" />
                </Grid>
            </Border>
        </VerticalStackLayout>
    </Grid>

</ContentPage>
🧠 Прокрутка вниз при новых сообщениях
Во ViewModel:
public event Action ScrollToEndRequested;
В MainPage.xaml.cs:
vm.ScrollToEndRequested += () => {
    var last = MessagesView.ItemsSource?.Cast<object>()?.LastOrDefault();
    if (last != null)
        MessagesView.ScrollTo(last, position: ScrollToPosition.End, animate: true);
};
Полный файл
MainPage.xaml.cs:
using ChatApp.ViewModel;

namespace ChatApp
{
    public partial class MainPage : ContentPage
    {

        public MainPage()
        {
            InitializeComponent();
            var vm = new ChatViewModel();
            vm.ScrollToEndRequested += () =>
            {
                if (MessagesView.ItemsSource is IEnumerable<object> messages)
                {
                    var last = messages.LastOrDefault();
                    if (last != null)
                        MessagesView.ScrollTo(last, position: ScrollToPosition.End, animate: true);
                }
            };
            BindingContext = vm;
        }

      
        private async void OnSendClicked(object sender, EventArgs e)
        {
            var text = UserInput.Text;
            UserInput.Text = string.Empty;

            if (BindingContext is ChatViewModel vm)
                await vm.SendMessageAsync(text);
        }

        private void OnSend(object sender, EventArgs e) => OnSendClicked(sender, e);
    }
}
Конвертеры
BoolToColorConverter
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ChatApp.Converters
{
    public class BoolToColorConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
            => (bool)value ? (Color)Application.Current.Resources["UserBuble"] : (Color)Application.Current.Resources["BotBuble"];

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
            => throw new NotImplementedException();
    }

}
BoolToColumnConverter
    using System.Globalization;
    using Microsoft.Maui.Controls;

    namespace ChatApp.Converters
    {
        public class BoolToColumnConverter : IValueConverter
        {
            public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
            {
                bool isUser = (bool)value;
                string target = (parameter as string)?.ToLower();

                // "message" означает блок с сообщением (Border), "time" — Label со временем
                return target switch
                {
                    "message" => isUser ? 1 : 0,
                    "time" => isUser ? 0 : 1,
                    _ => 0
                };
            }

            public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
                => throw new NotImplementedException();
        }
    }
BoolToCornerRadiusConverter
using System.Globalization;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Graphics;

namespace ChatApp.Converters
{
    public class BoolToCornerRadiusConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            bool isUser = (bool)value;

            // CornerRadius: TopLeft, TopRight, BottomRight, BottomLeft
            if (isUser)
                return new CornerRadius(20, 20, 20, 0); // правая сторона (пользователь)
            else
                return new CornerRadius(20, 20, 0, 20); // левая сторона (бот)
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
            => throw new NotImplementedException();
    }
}
InverseBoolConverter
using System.Globalization;

namespace ChatApp.Converters
{
    public class InverseBoolConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
            => !(bool)value;

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
            => throw new NotImplementedException();
    }
}
MessageAlignmentConverter
using System.Globalization;

namespace ChatApp.Converters
{
    public class MessageAlignmentConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            bool isUser = (bool)value;
            return isUser ? LayoutOptions.End : LayoutOptions.Start;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
            => throw new NotImplementedException();
    }
}
Регистрация конвертенов
<?xml version = "1.0" encoding = "UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:ChatApp"
             xmlns:converters="clr-namespace:ChatApp.Converters"
             xmlns:behaviors="clr-namespace:ChatApp.Behaviors"
             x:Class="ChatApp.App">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="Resources/Styles/Colors.xaml" />
                <ResourceDictionary Source="Resources/Styles/Styles.xaml" />
            </ResourceDictionary.MergedDictionaries>
            <Style TargetType="Entry">
                <Style.Behaviors>
                    <behaviors:RemoveUnderlineBehavior />
                </Style.Behaviors>
            </Style>
            <Style TargetType="Editor">
                <Style.Behaviors>
                    <behaviors:RemoveUnderlineBehavior />
                </Style.Behaviors>
            </Style>
            <converters:BoolToColorConverter x:Key="BoolToColorConverter" />
            <converters:MessageAlignmentConverter x:Key="MessageAlignmentConverter" />
            <converters:BoolToCornerRadiusConverter x:Key="CornerConverter" />
            <converters:BoolToColumnConverter x:Key="ColumnConverter" />
            <converters:InverseBoolConverter x:Key="InverseBoolConverter" />

        </ResourceDictionary>
    </Application.Resources>
</Application>
🎨 Пример цветов
<Color x:Key="UserBuble">#B9D9E6</Color>
<Color x:Key="BotBuble">#DDDDDD</Color>
📌 Заключение
Ты получил полностью рабочий UI для чата в .NET MAUI с:
  • адаптивным полем ввода,
  • красивыми пузырями сообщений,
  • плавным скроллом,
  • поддержкой Android без костылей.
Как подключить ChatGPT читайте здесь