WPF中的自定义光标?

Posted

技术标签:

【中文标题】WPF中的自定义光标?【英文标题】:Custom cursor in WPF? 【发布时间】:2010-09-07 23:45:13 【问题描述】:

我想在 WPF 应用程序中使用图像或图标作为自定义光标。我该怎么做?

【问题讨论】:

【参考方案1】:

您有两个基本选择:

    当鼠标光标在您的控件上时,通过设置this.Cursor = Cursors.None; 隐藏系统光标并使用您喜欢的任何技术绘制您自己的光标。然后,通过响应鼠标事件来更新光标的位置和外观。这里有两个例子:

    http://www.xamlog.com/2006/07/17/creating-a-custom-cursor/

    http://www.hanselman.com/blog/DeveloperDesigner.aspx 可以在此处找到其他示例:

    WPF Tutorial - How To Use Custom Cursors

    Setting the Cursor to Render Some Text While Dragging Getting fancy and using the Visual we are dragging for feedback [instead of a cursor] How can I drag and drop items between data bound ItemsControls?

    通过从 .cur 或 .ani 文件加载图像来创建新的 Cursor 对象。您可以在 Visual Studio 中创建和编辑这些类型的文件。还有一些免费的实用程序可以处理它们。基本上,它们是指定“热点”的图像(或动画图像),指示光标位于图像中的哪个点。

如果您选择从文件加载,请注意您需要一个绝对文件系统路径才能使用Cursor(string fileName) 构造函数。 Lamely,相对路径或 Pack URI 将不起作用。如果您需要从相对路径或与您的程序集打包的资源加载光标,您将需要从文件中获取流并将其传递给Cursor(Stream cursorStream) 构造函数。烦人但真实。

另一方面,在使用 XAML 属性加载光标时将光标指定为相对路径确实有效,您可以使用这一事实将光标加载到隐藏控件上,然后复制引用在另一个控件上使用。我没试过,但应该可以。

【讨论】:

另请注意,您可以从任何 WPF 内容动态构建光标。请参阅 ***.com/questions/2835502/… 了解如何完成此操作的示例。 我在上一条评论中发布的链接涉及旋转现有光标。我刚刚发布了这个问题的新答案(见下文),它讲述了如何将任意 Visual 转换为 Cursor。【参考方案2】:

与Peter mentioned 一样,如果您已经有一个 .cur 文件,则可以通过在资源部分创建一个虚拟元素,然后在需要时引用该虚拟光标,将其用作嵌入式资源。

例如,假设您想根据所选工具显示非标准光标。

添加到资源:

<Window.Resources>
    <ResourceDictionary>
        <TextBlock x:Key="CursorGrab" Cursor="Resources/Cursors/grab.cur"/>
        <TextBlock x:Key="CursorMagnify" Cursor="Resources/Cursors/magnify.cur"/>
    </ResourceDictionary>
</Window.Resources>

代码中引用的嵌入式游标示例:

if (selectedTool == "Hand")
    myCanvas.Cursor = ((TextBlock)this.Resources["CursorGrab"]).Cursor;
else if (selectedTool == "Magnify")
    myCanvas.Cursor = ((TextBlock)this.Resources["CursorMagnify"]).Cursor;
else
    myCanvas.Cursor = Cursor.Arrow;

【讨论】:

您是否有任何理由使用 TextBlock 来缓存首先定义 Cursor 属性的 FrameworkElement 上的 Cursor 引用? 没有理由; FrameworkElement 将是一个更好的选择。谢谢!【参考方案3】:

有一种比自己管理光标显示或使用 Visual Studio 构建大量自定义光标更简单的方法。

如果您有一个 FrameworkElement,您可以使用以下代码从中构造一个 Cursor:

public Cursor ConvertToCursor(FrameworkElement visual, Point hotSpot)

  int width = (int)visual.Width;
  int height = (int)visual.Height;

  // Render to a bitmap
  var bitmapSource = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32);
  bitmapSource.Render(visual);

  // Convert to System.Drawing.Bitmap
  var pixels = new int[width*height];
  bitmapSource.CopyPixels(pixels, width, 0);
  var bitmap = new System.Drawing.Bitmap(width, height, System.Drawing.Imaging.PixelFormat.Format32bppPArgb);
  for(int y=0; y<height; y++)
    for(int x=0; x<width; x++)
      bitmap.SetPixel(x, y, Color.FromArgb(pixels[y*width+x]));

  // Save to .ico format
  var stream = new MemoryStream();
  System.Drawing.Icon.FromHandle(resultBitmap.GetHicon()).Save(stream);

  // Convert saved file into .cur format
  stream.Seek(2, SeekOrigin.Begin);
  stream.WriteByte(2);
  stream.Seek(10, SeekOrigin.Begin);
  stream.WriteByte((byte)(int)(hotSpot.X * width));
  stream.WriteByte((byte)(int)(hotSpot.Y * height));
  stream.Seek(0, SeekOrigin.Begin);

  // Construct Cursor
  return new Cursor(stream);

请注意,您的 FrameworkElement 的大小必须是标准光标大小(例如 16x16 或 32x32),例如:

<Grid x:Name="customCursor" Width="32" Height="32">
  ...
</Grid>

它会这样使用:

someControl.Cursor = ConvertToCursor(customCursor, new Point(0.5, 0.5));

如果你有一个现有的图像,你的 FrameworkElement 显然可以是一个&lt;Image&gt; 控件,或者你可以使用 WPF 的内置绘图工具绘制任何你喜欢的东西。

请注意,有关 .cur 文件格式的详细信息,请访问 ICO (file format)。

【讨论】:

嘿,我尝试使用这段代码 sn-p 来定义带有 xaml 的自定义光标。不幸的是,它什么都不显示,而不是我定义的&lt;Image /&gt;-element。调试代码我意识到var pixels-array 在CopyPixels()-method 运行之后的每个像素只包含0。我在CopyPixels() 方法的stride 参数上遇到了错误,所以我根据我发现的其他一些sn-ps 稍微更改了代码:int stride = width * ((bitmapSource.Format.BitsPerPixel + 7) / 8); 除了代码看起来与上面相同。 visual 是:&lt;Image Height="32" Width="32"/&gt;【参考方案4】:

为了在 XAML 中使用自定义光标,我稍微更改了 code Ben McIntosh provided:

<Window.Resources>    
 <Cursor x:Key="OpenHandCursor">Resources/openhand.cur</Cursor>
</Window.Resources>

要使用光标,只需引用资源:

<StackPanel Cursor="StaticResource OpenHandCursor" />

【讨论】:

使用游标资源而不是框架元素“虚拟”更有意义【参考方案5】:

如果有人正在寻找 UIElement 本身作为光标,我结合了 Ray 和 Arcturus 的解决方案:

    public Cursor ConvertToCursor(UIElement control, Point hotSpot)
    
        // convert FrameworkElement to PNG stream
        var pngStream = new MemoryStream();
        control.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
        Rect rect = new Rect(0, 0, control.DesiredSize.Width, control.DesiredSize.Height);
        RenderTargetBitmap rtb = new RenderTargetBitmap((int)control.DesiredSize.Width, (int)control.DesiredSize.Height, 96, 96, PixelFormats.Pbgra32);

        control.Arrange(rect);
        rtb.Render(control);

        PngBitmapEncoder png = new PngBitmapEncoder();
        png.Frames.Add(BitmapFrame.Create(rtb));
        png.Save(pngStream);

        // write cursor header info
        var cursorStream = new MemoryStream();
        cursorStream.Write(new byte[2]  0x00, 0x00 , 0, 2);                               // ICONDIR: Reserved. Must always be 0.
        cursorStream.Write(new byte[2]  0x02, 0x00 , 0, 2);                               // ICONDIR: Specifies image type: 1 for icon (.ICO) image, 2 for cursor (.CUR) image. Other values are invalid
        cursorStream.Write(new byte[2]  0x01, 0x00 , 0, 2);                               // ICONDIR: Specifies number of images in the file.
        cursorStream.Write(new byte[1]  (byte)control.DesiredSize.Width , 0, 1);          // ICONDIRENTRY: Specifies image width in pixels. Can be any number between 0 and 255. Value 0 means image width is 256 pixels.
        cursorStream.Write(new byte[1]  (byte)control.DesiredSize.Height , 0, 1);         // ICONDIRENTRY: Specifies image height in pixels. Can be any number between 0 and 255. Value 0 means image height is 256 pixels.
        cursorStream.Write(new byte[1]  0x00 , 0, 1);                                     // ICONDIRENTRY: Specifies number of colors in the color palette. Should be 0 if the image does not use a color palette.
        cursorStream.Write(new byte[1]  0x00 , 0, 1);                                     // ICONDIRENTRY: Reserved. Should be 0.
        cursorStream.Write(new byte[2]  (byte)hotSpot.X, 0x00 , 0, 2);                    // ICONDIRENTRY: Specifies the horizontal coordinates of the hotspot in number of pixels from the left.
        cursorStream.Write(new byte[2]  (byte)hotSpot.Y, 0x00 , 0, 2);                    // ICONDIRENTRY: Specifies the vertical coordinates of the hotspot in number of pixels from the top.
        cursorStream.Write(new byte[4]                                                     // ICONDIRENTRY: Specifies the size of the image's data in bytes
                                          (byte)((pngStream.Length & 0x000000FF)),
                                          (byte)((pngStream.Length & 0x0000FF00) >> 0x08),
                                          (byte)((pngStream.Length & 0x00FF0000) >> 0x10),
                                          (byte)((pngStream.Length & 0xFF000000) >> 0x18)
                                       , 0, 4);
        cursorStream.Write(new byte[4]                                                     // ICONDIRENTRY: Specifies the offset of BMP or PNG data from the beginning of the ICO/CUR file
                                          (byte)0x16,
                                          (byte)0x00,
                                          (byte)0x00,
                                          (byte)0x00,
                                       , 0, 4);

        // copy PNG stream to cursor stream
        pngStream.Seek(0, SeekOrigin.Begin);
        pngStream.CopyTo(cursorStream);

        // return cursor stream
        cursorStream.Seek(0, SeekOrigin.Begin);
        return new Cursor(cursorStream);
    

【讨论】:

我会使用围绕您的流的语句来清理它,但除此之外,我对这种方法没有任何问题(与其他实现不同)。 我注意到在控件上调用 Arrange 会导致 ListBoxItems 和 TreeViewItems 暂时消失,只是在导致其父级的布局发生变化后重新出现(例如,扩展 TreeViewItem)。知道这是为什么吗?【参考方案6】:

一种非常简单的方法是在 Visual Studio 中将光标创建为 .cur 文件,然后将其添加到项目资源中。

那么当你想分配光标时,只需添加以下代码:

myCanvas.Cursor = new Cursor(new System.IO.MemoryStream(myNamespace.Properties.Resources.Cursor1));

【讨论】:

【参考方案7】:

另一种解决方案有点类似于 Ray 的解决方案,但不是缓慢而繁琐的像素复制,而是使用了一些 Windows 内部结构:

private struct IconInfo 
  public bool fIcon;
  public int xHotspot;
  public int yHotspot;
  public IntPtr hbmMask;
  public IntPtr hbmColor;


[DllImport("user32.dll")]
private static extern IntPtr CreateIconIndirect(ref IconInfo icon);

[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetIconInfo(IntPtr hIcon, ref IconInfo pIconInfo);

public Cursor ConvertToCursor(FrameworkElement cursor, Point HotSpot) 
  cursor.Arrange(new Rect(new Size(cursor.Width, cursor.Height)));
  var bitmap = new RenderTargetBitmap((int)cursor.Width, (int)cursor.Height, 96, 96, PixelFormats.Pbgra32);
  bitmap.Render(cursor);

  var info = new IconInfo();
  GetIconInfo(bitmap.ToBitmap().GetHicon(), ref info);
  info.fIcon = false;
  info.xHotspot = (byte)(HotSpot.X * cursor.Width);
  info.yHotspot = (byte)(HotSpot.Y * cursor.Height);

  return CursorInteropHelper.Create(new SafeFileHandle(CreateIconIndirect(ref info), true));

中间有一个扩展方法,我更喜欢在扩展类中使用这种情况:

using DW = System.Drawing;

public static DW.Bitmap ToBitmap(this BitmapSource bitmapSource) 
  var bitmap = new DW.Bitmap(bitmapSource.PixelWidth, bitmapSource.PixelHeight, DW.Imaging.PixelFormat.Format32bppPArgb);
  var data = bitmap.LockBits(new DW.Rectangle(DW.Point.Empty, bitmap.Size), DW.Imaging.ImageLockMode.WriteOnly, DW.Imaging.PixelFormat.Format32bppPArgb);
  bitmapSource.CopyPixels(Int32Rect.Empty, data.Scan0, data.Height * data.Stride, data.Stride);
  bitmap.UnlockBits(data);
  return bitmap;

有了这一切,它相当简单明了。

而且,如果您碰巧不需要指定自己的热点,您甚至可以将其缩短(您也不需要结构或 P/Invokes):

public Cursor ConvertToCursor(FrameworkElement cursor, Point HotSpot) 
  cursor.Arrange(new Rect(new Size(cursor.Width, cursor.Height)));
  var bitmap = new RenderTargetBitmap((int)cursor.Width, (int)cursor.Height, 96, 96, PixelFormats.Pbgra32);
  bitmap.Render(cursor);
  var icon = System.Drawing.Icon.FromHandle(bitmap.ToBitmap().GetHicon());
  return CursorInteropHelper.Create(new SafeFileHandle(icon.Handle, true));

【讨论】:

这个效果很好(从我想要的任何 WPF 视觉对象中创建光标都很棒),但是,每当关联对象为被摧毁。唯一不能得到它的方法是创建一个单例游标并在任何地方重用它。您知道的任何原因都会导致 SEH 异常?我可以猜一整天,但似乎用于为光标创建图像的对象被释放了,并且 Cursor 类炸毁了它的 b/c。 很好的例子,效果很好,但有一个错误,即info.yHotspot = (byte)(HotSpot.X * cursor.Height);(应该是 HotSpot.Y,而不是 HotSpot.X)。此示例还通过按源位图尺寸对其进行缩放来更改原始热点代码的范围,因此在指定偏移量时请记住这一点。【参考方案8】:

我想从项目资源中加载自定义光标文件,但遇到了类似问题。我在互联网上搜索了一个解决方案,但没有找到我需要的:在运行时将this.Cursor 设置为存储在我的项目的资源文件夹中的自定义光标。 我试过 Ben 的 xaml 解决方案,但觉得它不够优雅。 彼得艾伦说:

很糟糕,相对路径或 Pack URI 不起作用。如果您需要从相对路径或与程序集一起打包的资源加载游标,则需要从文件中获取流并将其传递给 Cursor(Stream cursorStream) 构造函数。烦人但真实。

我偶然发现了一个很好的方法来解决我的问题:

    System.Windows.Resources.StreamResourceInfo info = 
        Application.GetResourceStream(new 
        Uri("/MainApp;component/Resources/HandDown.cur", UriKind.Relative));

    this.Cursor = new System.Windows.Input.Cursor(info.Stream); 

MainApp 应替换为您的应用程序的名称。 Resources 应替换为项目中 *.cur 文件的相对文件夹路径。

【讨论】:

"MainApp" 应替换为您的应用程序的名称。 “资源”应替换为项目中 *.cur 文件的相对文件夹路径。【参考方案9】:

你可以试试这个

<Window Cursor=""C:\WINDOWS\Cursors\dinosaur.ani"" />

【讨论】:

【参考方案10】:

你可以通过像这样的代码来做到这一点

this.Cursor = new Cursor(@"<your address of icon>");

【讨论】:

【参考方案11】:

还可以查看 Scott Hanselman 的 BabySmash (www.codeplex.com/babysmash)。他使用了一种更“蛮力”的方法来隐藏窗口光标并在画布上显示他的新光标,然后将光标移动到“真实”光标的位置

在这里阅读更多: http://www.hanselman.com/blog/DeveloperDesigner.aspx

【讨论】:

【参考方案12】:

如果你使用的是visual studio,你可以

    新建一个光标文件 复制/粘贴图片 将其保存到 .cur 文件中。

【讨论】:

【参考方案13】:

它可能在 Visual Studio 2017 中发生了变化,但我能够将 .cur 文件作为嵌入式资源引用:

<Setter
    Property="Cursor"
    Value="/assembly-name;component/location-name/curser-name.cur" />

【讨论】:

【参考方案14】:

确保所有 GDI 资源(例如 bmp.GetHIcon)都被释放。否则你最终会出现内存泄漏。以下代码(图标的扩展方法)非常适用于 WPF。它会在右下角创建一个带有小图标的箭头光标。

备注:此代码使用图标来创建光标。它不使用当前的 UI 控件。

    public static Cursor CreateCursor(this Icon icon, bool includeCrossHair, System.Drawing.Color crossHairColor)
    
        if (icon == null)
            return Cursors.Arrow;

        // create an empty image
        int width = icon.Width;
        int height = icon.Height;

        using (var cursor = new Bitmap(width * 2, height * 2))
        
            // create a graphics context, so that we can draw our own cursor
            using (var gr = System.Drawing.Graphics.FromImage(cursor))
            
                // a cursor is usually 32x32 pixel so we need our icon in the lower right part of it
                gr.DrawIcon(icon, new Rectangle(width, height, width, height));

                if (includeCrossHair)
                
                    using (var pen = new System.Drawing.Pen(crossHairColor))
                    
                        // draw the cross-hair
                        gr.DrawLine(pen, width - 3, height, width + 3, height);
                        gr.DrawLine(pen, width, height - 3, width, height + 3);
                    
                
            

            try
            
                using (var stream = new MemoryStream())
                
                    // Save to .ico format
                    var ptr = cursor.GetHicon();
                    var tempIcon = Icon.FromHandle(ptr);
                    tempIcon.Save(stream);

                    int x = cursor.Width/2;
                    int y = cursor.Height/2;

                    #region Convert saved stream into .cur format

                    // set as .cur file format
                    stream.Seek(2, SeekOrigin.Begin);
                    stream.WriteByte(2);

                    // write the hotspot information
                    stream.Seek(10, SeekOrigin.Begin);
                    stream.WriteByte((byte)(width));
                    stream.Seek(12, SeekOrigin.Begin);
                    stream.WriteByte((byte)(height));
                    
                    // reset to initial position
                    stream.Seek(0, SeekOrigin.Begin);

                    #endregion


                    DestroyIcon(tempIcon.Handle);  // destroy GDI resource

                    return new Cursor(stream);
                
            
            catch (Exception)
            
                return Cursors.Arrow;
            
        
    

    /// <summary>
    /// Destroys the icon.
    /// </summary>
    /// <param name="handle">The handle.</param>
    /// <returns></returns>
    [DllImport("user32.dll", CharSet = CharSet.Auto)]
    public extern static Boolean DestroyIcon(IntPtr handle);

【讨论】:

【参考方案15】:

这将使用附加属性将存储在项目中的任何图像转换为光标。图片必须编译为资源!

示例

<Button MyLibrary:FrameworkElementExtensions.Cursor=""MyLibrary:Uri MyAssembly, MyImageFolder/MyImage.png""/>

FrameworkElementExtensions

using System;
using System.Windows;
using System.Windows.Media;

public static class FrameworkElementExtensions

    #region Cursor

    public static readonly DependencyProperty CursorProperty = DependencyProperty.RegisterAttached("Cursor", typeof(Uri), typeof(FrameworkElementExtensions), new UIPropertyMetadata(default(Uri), OnCursorChanged));
    public static Uri GetCursor(FrameworkElement i) => (Uri)i.GetValue(CursorProperty);
    public static void SetCursor(FrameworkElement i, Uri input) => i.SetValue(CursorProperty, input);
    static void OnCursorChanged(object sender, DependencyPropertyChangedEventArgs e)
    
        if (sender is FrameworkElement frameworkElement)
        
            if (GetCursor(frameworkElement) != null)
                frameworkElement.Cursor = new ImageSourceConverter().ConvertFromString(((Uri)e.NewValue).OriginalString).As<ImageSource>().Bitmap().Cursor(0, 0).Convert();
        
    

    #endregion

ImageSourceExtensions

using System.Drawing;
using System.Windows.Media;
using System.Windows.Media.Imaging;

public static class ImageSourceExtensions

    public static Bitmap Bitmap(this ImageSource input) => input.As<BitmapSource>().Bitmap();

BitmapSourceExtensions

using System.IO;
using System.Windows.Media.Imaging;

public static class BitmapSourceExtensions

    public static System.Drawing.Bitmap Bitmap(this BitmapSource input)
    
        if (input == null)
            return null;

        System.Drawing.Bitmap result;
        using (var outStream = new MemoryStream())
        
            var encoder = new PngBitmapEncoder();
            encoder.Frames.Add(BitmapFrame.Create(input));
            encoder.Save(outStream);
            result = new System.Drawing.Bitmap(outStream);
        
        return result;
    

位图扩展

using System;
using System.Drawing;
using System.Runtime.InteropServices;

public static class BitmapExtensions


    [StructLayout(LayoutKind.Sequential)]
    public struct ICONINFO
    
        /// <summary>
        /// Specifies whether this structure defines an icon or a cursor. A value of TRUE specifies an icon; FALSE specifies a cursor. 
        /// </summary>
        public bool fIcon;

        /// <summary>
        /// Specifies the x-coordinate of a cursor's hot spot. If this structure defines an icon, the hot spot is always in the center of the icon, and this member is ignored.
        /// </summary>
        public Int32 xHotspot;

        /// <summary>
        /// Specifies the y-coordinate of the cursor's hot spot. If this structure defines an icon, the hot spot is always in the center of the icon, and this member is ignored. 
        /// </summary>
        public Int32 yHotspot;

        /// <summary>
        /// (HBITMAP) Specifies the icon bitmask bitmap. If this structure defines a black and white icon, this bitmask is formatted so that the upper half is the icon AND bitmask and the lower half is the icon XOR bitmask. Under this condition, the height should be an even multiple of two. If this structure defines a color icon, this mask only defines the AND bitmask of the icon. 
        /// </summary>
        public IntPtr hbmMask;

        /// <summary>
        /// (HBITMAP) Handle to the icon color bitmap. This member can be optional if this structure defines a black and white icon. The AND bitmask of hbmMask is applied with the SRCAND flag to the destination; subsequently, the color bitmap is applied (using XOR) to the destination by using the SRCINVERT flag. 
        /// </summary>
        public IntPtr hbmColor;
    

    [DllImport("user32.dll")]
    static extern IntPtr CreateIconIndirect([In] ref ICONINFO piconinfo);

    [DllImport("user32.dll")]
    static extern bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo);

    [DllImport("user32.dll", SetLastError = true)]
    public static extern bool DestroyIcon(IntPtr hIcon);

    public static System.Windows.Forms.Cursor Cursor(this Bitmap input, int hotX, int hotY)
    
        ICONINFO Info = new ICONINFO();
        IntPtr Handle = input.GetHicon();
        GetIconInfo(Handle, out Info);

        Info.xHotspot = hotX;
        Info.yHotspot = hotY;
        Info.fIcon = false;

        IntPtr h = CreateIconIndirect(ref Info);
        return new System.Windows.Forms.Cursor(h);
    

光标扩展

using Microsoft.Win32.SafeHandles;

public static class CursorExtensions

    public static System.Windows.Input.Cursor Convert(this System.Windows.Forms.Cursor Cursor)
    
        SafeFileHandle h = new SafeFileHandle(Cursor.Handle, false);
        return System.Windows.Interop.CursorInteropHelper.Create(h);
    

作为

public static Type As<Type>(this object input) => input is Type ? (Type)input : default;

Uri

using System;
using System.Windows.Markup;

public class Uri : MarkupExtension

    public string Assembly  get; set;  = null;

    public string RelativePath  get; set; 

    public Uri(string relativePath) : base()
    
        RelativePath = relativePath;
    

    public Uri(string assembly, string relativePath) : this(relativePath)
    
        Assembly = assembly;
    

    static Uri Get(string assemblyName, string relativePath) => new Uri($"pack://application:,,,/assemblyName;component/relativePath", UriKind.Absolute);

    public override object ProvideValue(IServiceProvider serviceProvider)
    
        if (Assembly == null)
            return new System.Uri(RelativePath, UriKind.Relative);

        return Get(Assembly, RelativePath);
    

【讨论】:

以上是关于WPF中的自定义光标?的主要内容,如果未能解决你的问题,请参考以下文章

SearchView 中的自定义光标颜色

WPF中的自定义热键

wpf应用程序中的自定义组合框

WPF中的自定义控件实例

WPF 创建自定义鼠标光标指针

WPF中的自定义模型绑定[重复]