WPF实现双击修改文本内容

18 阅读6分钟

1、假如我们的界面现在是

image

我打算修改按钮的Content 双击 image 回车 image

2、要实现这个功能我们需要用到附加属性

代码如下 xaml:

<Button Width="200" Height="30" attached:EditBehavior.EnableDoubleClick="true" Content="{Binding Content}"/>

cs代码:

/// <summary>
/// 附加行为类,为Button提供双击编辑内容的功能
/// 支持多次编辑,编辑完成后自动恢复绑定关系
/// </summary>
public static class EditBehavior
{
    #region 私有嵌套类

    /// <summary>
    /// 存储绑定的完整配置信息
    /// 用于在编辑完成后重建绑定
    /// </summary>
    private class BindingConfig
    {
        /// <summary>绑定路径,如 "Content"</summary>
        public string Path { get; set; }

        /// <summary>绑定的源对象(显式指定的Source)</summary>
        public object Source { get; set; }

        /// <summary>绑定模式(OneWay/TwoWay等)</summary>
        public BindingMode Mode { get; set; }

        /// <summary>更新源的方式(PropertyChanged/LostFocus等)</summary>
        public UpdateSourceTrigger UpdateSourceTrigger { get; set; }

        /// <summary>值转换器</summary>
        public IValueConverter Converter { get; set; }

        /// <summary>转换器参数</summary>
        public object ConverterParameter { get; set; }

        /// <summary>字符串格式化</summary>
        public string StringFormat { get; set; }

        /// <summary>相对源配置</summary>
        public RelativeSource RelativeSource { get; set; }

        /// <summary>ViewModel实例(用于反射更新后备方案)</summary>
        public object DataItem { get; set; }

        /// <summary>ViewModel中的属性名(用于反射更新后备方案)</summary>
        public string PropertyName { get; set; }
    }

    #endregion

    #region 私有字段

    /// <summary>缓存每个按钮的绑定配置</summary>
    private static readonly Dictionary<Button, BindingConfig> _bindingConfigCache = new Dictionary<Button, BindingConfig>();

    /// <summary>标记每个按钮是否已保存过配置(避免重复保存)</summary>
    private static readonly Dictionary<Button, bool> _hasConfigSaved = new Dictionary<Button, bool>();

    /// <summary>记录每个按钮当前活动的TextBox实例(用于事件处理和清理)</summary>
    private static readonly Dictionary<Button, TextBox> _activeTextBoxes = new Dictionary<Button, TextBox>();

    #endregion

    #region 附加属性定义

    /// <summary>
    /// 附加属性:控制是否处于编辑模式
    /// 当设置为true时,按钮进入编辑状态(显示TextBox)
    /// </summary>
    public static readonly DependencyProperty IsEditingProperty =
        DependencyProperty.RegisterAttached("IsEditing", typeof(bool), typeof(EditBehavior),
        new PropertyMetadata(false, OnIsEditingChanged));

    public static void SetIsEditing(DependencyObject obj, bool value) => obj.SetValue(IsEditingProperty, value);
    public static bool GetIsEditing(DependencyObject obj) => (bool)obj.GetValue(IsEditingProperty);

    /// <summary>
    /// 附加属性:是否启用双击编辑
    /// 设置为true时,双击按钮自动进入编辑模式
    /// </summary>
    public static readonly DependencyProperty EnableDoubleClickProperty =
        DependencyProperty.RegisterAttached("EnableDoubleClick", typeof(bool), typeof(EditBehavior),
        new PropertyMetadata(false, OnEnableDoubleClickChanged));

    public static void SetEnableDoubleClick(DependencyObject obj, bool value) => obj.SetValue(EnableDoubleClickProperty, value);
    public static bool GetEnableDoubleClick(DependencyObject obj) => (bool)obj.GetValue(EnableDoubleClickProperty);

    #endregion

    #region 附加属性回调

    /// <summary>
    /// EnableDoubleClick属性变化时的回调
    /// 为按钮注册/取消注册双击事件
    /// </summary>
    private static void OnEnableDoubleClickChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is Button button && (bool)e.NewValue)
        {
            button.MouseDoubleClick += (s, args) => SetIsEditing(button, true);
        }
    }

    /// <summary>
    /// IsEditing属性变化时的回调
    /// 根据新值进入编辑模式或退出编辑模式
    /// </summary>
    private static void OnIsEditingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is Button button)
        {
            if ((bool)e.NewValue)
                EnterEditMode(button);   // 进入编辑模式
            else
                ExitEditMode(button);    // 退出编辑模式
        }
    }

    #endregion

    #region 核心逻辑

    /// <summary>
    /// 保存按钮的绑定配置
    /// 仅在第一次进入编辑时执行,后续编辑复用缓存的配置
    /// </summary>
    /// <param name="button">目标按钮</param>
    private static void SaveBindingConfig(Button button)
    {
        // 已保存过则跳过
        if (_hasConfigSaved.ContainsKey(button) && _hasConfigSaved[button])
            return;

        // 获取当前的绑定表达式
        var expression = BindingOperations.GetBindingExpression(button, Button.ContentProperty);
        if (expression == null) return;

        // 获取原始的Binding对象
        var parentBinding = expression.ParentBinding;
        if (parentBinding == null) return;

        var config = new BindingConfig();

        // 保存绑定路径
        config.Path = parentBinding.Path?.Path;

        // 保存源对象(Source或RelativeSource二选一)
        if (parentBinding.Source != null)
        {
            config.Source = parentBinding.Source;
        }
        else if (parentBinding.RelativeSource != null)
        {
            config.RelativeSource = parentBinding.RelativeSource;
        }

        // 保存绑定行为配置
        config.Mode = parentBinding.Mode;
        config.UpdateSourceTrigger = parentBinding.UpdateSourceTrigger;
        config.Converter = parentBinding.Converter;
        config.ConverterParameter = parentBinding.ConverterParameter;
        config.StringFormat = parentBinding.StringFormat;

        // 保存ViewModel信息(用于反射后备方案)
        config.DataItem = expression.DataItem;
        config.PropertyName = expression.ResolvedSourcePropertyName;

        _bindingConfigCache[button] = config;
        _hasConfigSaved[button] = true;
    }

    /// <summary>
    /// 重建按钮的绑定
    /// 编辑完成后调用,确保下次编辑时绑定仍然存在
    /// </summary>
    /// <param name="button">目标按钮</param>
    private static void RestoreBinding(Button button)
    {
        if (!_bindingConfigCache.TryGetValue(button, out var config)) return;

        // 创建新的Binding对象并使用保存的配置
        var newBinding = new Binding();

        if (!string.IsNullOrEmpty(config.Path))
            newBinding.Path = new PropertyPath(config.Path);

        if (config.Source != null)
        {
            newBinding.Source = config.Source;
        }
        else if (config.RelativeSource != null)
        {
            newBinding.RelativeSource = config.RelativeSource;
        }

        newBinding.Mode = config.Mode;
        newBinding.UpdateSourceTrigger = config.UpdateSourceTrigger;
        newBinding.Converter = config.Converter;
        newBinding.ConverterParameter = config.ConverterParameter;
        newBinding.StringFormat = config.StringFormat;

        // 先清除本地值,再重新建立绑定
        button.ClearValue(Button.ContentProperty);
        BindingOperations.SetBinding(button, Button.ContentProperty, newBinding);

        // 手动更新目标,确保UI立即显示正确的值
        var newExpression = BindingOperations.GetBindingExpression(button, Button.ContentProperty);
        newExpression?.UpdateTarget();
    }

    /// <summary>
    /// 通过反射直接更新ViewModel的属性值
    /// 当绑定不存在时的后备方案
    /// </summary>
    /// <param name="button">目标按钮</param>
    /// <param name="newValue">新值</param>
    private static void UpdateViewModelByReflection(Button button, string newValue)
    {
        if (_bindingConfigCache.TryGetValue(button, out var config) &&
            config.DataItem != null &&
            !string.IsNullOrEmpty(config.PropertyName))
        {
            var prop = config.DataItem.GetType().GetProperty(config.PropertyName);
            if (prop != null && prop.CanWrite)
            {
                object valueToSet = newValue;
                // 如果属性类型不是string,尝试转换
                if (prop.PropertyType != typeof(string))
                {
                    try
                    {
                        valueToSet = Convert.ChangeType(newValue, prop.PropertyType);
                    }
                    catch
                    {
                        valueToSet = prop.GetValue(config.DataItem);
                    }
                }
                prop.SetValue(config.DataItem, valueToSet);
            }
        }
    }

    /// <summary>
    /// 进入编辑模式
    /// 将按钮的内容替换为TextBox
    /// </summary>
    /// <param name="button">目标按钮</param>
    private static void EnterEditMode(Button button)
    {
        if (button.Content is string originalText)
        {
            // 保存绑定配置(仅第一次)
            SaveBindingConfig(button);

            // 创建编辑用的TextBox
            var textBox = new TextBox
            {
                VerticalContentAlignment = VerticalAlignment.Center,
                HorizontalContentAlignment = HorizontalAlignment.Center,
                Width = button.Width,
                Height = button.Height,
                Text = originalText,
                Tag = originalText,  // Tag保存原值,用于取消编辑
                MinWidth = 50
            };

            // 注册事件
            textBox.LostFocus += TextBox_LostFocus;
            textBox.KeyDown += TextBox_KeyDown;

            // 记录活动的TextBox
            _activeTextBoxes[button] = textBox;

            // 替换Content
            button.Content = textBox;

            // 获取焦点
            textBox.Focus();
        }
    }

    /// <summary>
    /// 退出编辑模式
    /// 清理TextBox的事件和缓存
    /// </summary>
    private static void ExitEditMode(Button button)
    {
        if (_activeTextBoxes.TryGetValue(button, out var textBox))
        {
            textBox.LostFocus -= TextBox_LostFocus;
            textBox.KeyDown -= TextBox_KeyDown;
            _activeTextBoxes.Remove(button);
        }
    }

    #endregion

    #region TextBox事件处理

    /// <summary>
    /// TextBox的键盘按键处理
    /// Enter:保存并退出编辑
    /// Escape:取消编辑,恢复原值
    /// </summary>
    private static void TextBox_KeyDown(object sender, KeyEventArgs e)
    {
        var textBox = sender as TextBox;
        if (textBox == null) return;

        // 查找所属的Button
        Button button = null;
        foreach (var kvp in _activeTextBoxes)
        {
            if (kvp.Value == textBox)
            {
                button = kvp.Key;
                break;
            }
        }

        if (button == null) return;

        if (e.Key == Key.Enter)
        {
            string newValue = textBox.Text;

            // 尝试通过绑定更新(如果绑定还在)
            var expression = BindingOperations.GetBindingExpression(button, Button.ContentProperty);
            if (expression != null)
            {
                button.Content = newValue;
                expression.UpdateSource();  // 主动推送值到ViewModel
            }
            else
            {
                // 绑定已丢失,使用反射后备方案
                UpdateViewModelByReflection(button, newValue);
            }

            // 重建绑定,确保下次编辑还能正常工作
            RestoreBinding(button);

            // 退出编辑模式
            SetIsEditing(button, false);
            e.Handled = true;
        }
        else if (e.Key == Key.Escape)
        {
            // 恢复原值
            button.Content = textBox.Tag;
            SetIsEditing(button, false);
            e.Handled = true;
        }
    }

    /// <summary>
    /// TextBox失去焦点时的处理
    /// 不保存修改,直接恢复原值(符合"退出焦点不能修改"的需求)
    /// </summary>
    private static void TextBox_LostFocus(object sender, RoutedEventArgs e)
    {
        var textBox = sender as TextBox;
        if (textBox == null) return;

        Button button = null;
        foreach (var kvp in _activeTextBoxes)
        {
            if (kvp.Value == textBox)
            {
                button = kvp.Key;
                break;
            }
        }

        if (button == null) return;

        // 恢复原值,不做保存
        button.Content = textBox.Tag;
        SetIsEditing(button, false);
    }

    #endregion

    #region 公开辅助方法

    /// <summary>
    /// 手动清理指定按钮的缓存
    /// 当按钮被销毁时建议调用,避免内存泄漏
    /// </summary>
    public static void ClearCache(Button button)
    {
        if (_bindingConfigCache.ContainsKey(button))
            _bindingConfigCache.Remove(button);
        if (_hasConfigSaved.ContainsKey(button))
            _hasConfigSaved.Remove(button);
    }

    #endregion
}

3、 优缺点分析

优点

方面说明
用户体验双击进入编辑、Enter保存、Escape取消、失去焦点放弃修改,符合直觉操作
多次编辑支持通过退出时重建绑定,解决了普通方案中“只能编辑一次”的缺陷
绑定完整保留保存了Binding的完整配置(Mode、Converter、StringFormat等),重建后功能完全一致
VM零侵入ViewModel不需要添加任何编辑相关的属性或命令,只需正常实现INotifyPropertyChanged
双路保障优先使用绑定更新,失败时自动降级为反射,提高了鲁棒性
配置灵活支持Source、RelativeSource等多种绑定源
事件自动清理退出编辑时自动移除TextBox的事件订阅,避免内存泄漏
使用简单只需设置 local:EditBehavior.EnableDoubleClick="True" 即可

缺点

方面说明
实现复杂代码量较大,涉及绑定系统底层操作(获取BindingExpression、ParentBinding、重建绑定)
反射性能开销反射后备方案有轻微性能开销(但UI交互场景下可忽略)
字典缓存管理使用静态Dictionary缓存,如果动态创建/销毁大量按钮需要手动调用ClearCache
不支持MultiBinding当前只处理普通的Binding,MultiBinding需要额外扩展
不支持动态DataContext如果在两次编辑之间DataContext发生变化,重建的绑定可能指向错误的源(罕见场景)
对Width/Height的依赖TextBox强制使用了Button的Width/Height,如果按钮宽高会变化可能有布局问题
仅限Button当前只针对Button实现,扩展到其他控件需要修改

适用场景建议

  • 适合:需要就地编辑文本的Button场景,如可编辑标签、配置项等
  • 适合:希望保持MVVM纯净、不想污染ViewModel的团队
  • ⚠️ 慎用:按钮宽高会动态变化的场景
  • ⚠️ 慎用:需要频繁创建/销毁大量可编辑按钮的场景(注意缓存清理)
  • 不适合:需要MultiBinding或PriorityBinding的复杂绑定场景