准备.Net转前端开发-WPF界面框架那些事,值得珍藏的8个问题

Posted Heavi的博客

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了准备.Net转前端开发-WPF界面框架那些事,值得珍藏的8个问题相关的知识,希望对你有一定的参考价值。

题外话

    不出意外,本片内容应该是最后一篇关于.Net技术的博客,做.Net的伙伴们忽喷忽喷。.Net挺好的,微软最*在跨*台方面搞的水深火热,更新也比较频繁,而且博客园的很多大牛也写的有跨*台相关技术的博客。做.Net开发块五年时间,个人没本事,没做出啥成绩。想象偶像梅球王,年龄都差不多,为啥差别就这么大。不甘*庸,想趁机会挑战下其他方面的技术,正好有一个机会转前段开发。

    对于目前正在从事或者工作中会用到WPF技术开发的伙伴,此片内容不得不收藏,本片介绍的八个问题都是在WPF开发工作中经常使用到并且很容易搞错的技术点。能轻车熟路的掌握这些问题,那么你的开发效率肯定不会低。

WPF相关链接

No.1 准备.Net转前端开发-WPF界面框架那些事,搭建基础框架

No.2 准备.Net转前端开发-WPF界面框架那些事,UI快速实现法

No.3 准备.Net转前端开发-WPF界面框架那些事,值得珍藏的8个问题

8个问题归纳

No.1.WrapPane和ListBox强强配合;

No.2.给数据绑定转换器Converter传多个参数;

No.3.搞清楚路由事件的两种策略:隧道策略和冒泡策略;

No.4.Conveter比你想象的强大;

No.5.ItemsControl下操作指令的绑定;

No.6.StaticResource和DynamicResource区别;

No.7.数据的几种绑定形式;

No.8.附加属性和依赖属性;

八大经典问题

1. WrapPane和ListBox强强配合

    重点:WrapPanel在ListBox面板中的实现方式

    看一张需求图片,图片中需要实现的功能是:左边面板显示内容,右边是一个图片列表,由于图片比较多,所以在左向左拖动中间分隔线时,图片根据右栏的宽度的增加,每行可显示多张图片。功能是很简单,但有些人写出这个功能需要2个小时,而有些人只需要十几分钟

image

    循序渐进,我们先实现每行显示一张图片,直接使用StackPanel重写ItemPanel模板,并设置Orientation为Vertical。实现结果如下:

image

   源代码如下:

<Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="2" />
            <ColumnDefinition Width="120" />
        </Grid.ColumnDefinitions>
        <Border BorderBrush="Green" Grid.Column="0">
            <Image Source="{Binding ElementName=LBoxImages, Path=SelectedItem.Source}" />
        </Border>
        <GridSplitter Grid.Column="1" VerticalAlignment="Stretch" HorizontalAlignment="Center" BorderThickness="1" BorderBrush="Green" />
        <ListBox Name="LBoxImages" Grid.Column="2">
            <ListBox.ItemsPanel>
                <ItemsPanelTemplate>
                    <StackPanel Orientation="Vertical" />
                </ItemsPanelTemplate>
            </ListBox.ItemsPanel>
            <Image Source="/Images/g1.jpg" Width="100" Height="80" />
            <Image Source="/Images/g2.jpg" Width="100" Height="80" />
            <Image Source="/Images/g3.jpg" Width="100" Height="80" />
            <Image Source="/Images/g4.jpg" Width="100" Height="80" />
            <Image Source="/Images/g5.jpg" Width="100" Height="80" />
            <Image Source="/Images/g7.jpg" Width="100" Height="80" />
            <Image Source="/Images/g8.jpg" Width="100" Height="80" />
        </ListBox>
    </Grid>

    分析执行结果图,游览显示的图片列表,不管ListBox有多宽,总是只显示一行,现在我们考虑实现每行根据ListBox宽度自动显示多张图片。首先,StackPanel是不支持这样的显示,而WrapPanel可动态排列每行。所以需要把StackPanel替换为WrapPanel并按行优先排列。修改代码ItemsPanelTemplate代码:

<ItemsPanelTemplate>
            <WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>

    再看看显示结果:

image

    和我们的预期效果不一样,为什么会这样?正是这个问题让很多人花几个小时都没解决。第一个问题:如果要实现自动换行我们得限制WrapPanel的宽度,所以必须显示的设置宽度,这个时候很多人都考虑的是把WrapPanel的宽度和ListBox的宽度一致。代码如下:

<ListBox.ItemsPanel>
                <ItemsPanelTemplate>
                    <WrapPanel Orientation="Horizontal" Width="{Binding RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type ListBox}}, Path=Width}" />
                </ItemsPanelTemplate>
</ListBox.ItemsPanel>

    需要强调的是,这样修改后结果还是一样的。为什么会这样?第二个问题:里出现的问题和CSS和html相似,ListBox有边框,所以实际显示内容的Width小于ListBox的Width。而你直接设置WrapPanel的宽度等于ListBox的宽度,肯定会显示不完,所以滚动条还是存在。因此,我们必须让WrapPanel的Width小于ListBox的Width。我们可以写个Conveter,让WrapPanel的Width等于ListBox的Width减去10个像素。Converter代码如下:

public class SubConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            if(value == null)
            {
                throw new ArgumentNullException("value");
            }
            int listBoxWidth;
            if(!int.TryParse(value.ToString(), out listBoxWidth))
            {
                throw new Exception("invalid value!");
            }
            int subValue = 0;
            if(parameter != null)
            {
                int.TryParse(parameter.ToString(), out subValue);
            }
            return listBoxWidth - subValue;
        }

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

    为WrapPanel的Width绑定添加Converter,代码如下:

<ItemsPanelTemplate>
                    <WrapPanel Orientation="Horizontal" Width="{Binding RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type ListBox}}, Path=ActualWidth, Converter={StaticResource SubConverter}, ConverterParameter=10}" />
                </ItemsPanelTemplate>

     这下就大功告成,看看结果效果,是不是每行自动显示多张图片了?

    image

2.给数据绑定转换器Converter传多个参数

    重点:MultiBinding和IMultiValueConverter。

    看看下面功能图片:

image

    上图中,有报警、提示、总计三个统计数。总计数是报警和提示数量的总和,如果报警和提示数发生变化,总计数自动更新。其实这个功能也非常简单,很多人实现的方式是定义一个类,包含三个属性。代码如下:

public class Counter : INotifyPropertyChanged
    {
        private int _alarmCount;
        public int AlarmCount
        {
            get { return _alarmCount; }
            set
            {
                if(_alarmCount != value)
                {
                    _alarmCount = value;
                    OnPropertyChanged("AlarmCount");
                    OnPropertyChanged("TotalCount");
                }
            }
        }

        private int _messageCount;
        public int MessageCount
        {
            get { return _messageCount; }
            set
            {
                if(_messageCount != value)
                {
                    _messageCount = value;
                    OnPropertyChanged("MessageCount");
                    OnPropertyChanged("TotalCount");
                }
            }
        }

        public int TotalCount
        {
            get { return AlarmCount + MessageCount; }
        }
    }

    这里明显有个问题时,如果统计的数量比较多,那么TotalCount需要加上多个数,并且每个数据属性都得添加OnPropertyChanged("TotalCount")触发界面更新TotalCount数据。接下来我们就考虑考虑用Converter去实现该功能,很多人都知道IValueConverter,但有些还没怎么使用过IMultiValueConverter接口。IMultiValueConverter可接收多个参数。通过IMultiValueConverter,可以让我们不添加任何后台代码以及耦合的属性。IMultiValueConverter实现代码如下:

public class AdditionConverter : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            if(values == null || values.Length != 2)
            {
                return 0;
            }
            int alarmCount = 0;
            if(values[0] != null)
            {
                int.TryParse(values[0].ToString(), out alarmCount);
            }
            int infoCount = 0;
            if(values[1] != null)
            {
                int.TryParse(values[1].ToString(), out infoCount);
            }

            return (alarmCount + infoCount).ToString();
        }

        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }

    接下来就是通过MultiBinding实现多绑定。代码如下:

<Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>
        <StackPanel Margin="10" Grid.Column="0" Orientation="Horizontal" Background="Red">
            <Label VerticalAlignment="Center" Content="报警:" />
            <TextBox Name="TBoxAlarm" Background="Transparent" Height="30" Width="60" VerticalContentAlignment="Center" VerticalAlignment="Center" />
        </StackPanel>
        <StackPanel Margin="10" Grid.Column="1" Orientation="Horizontal" Background="Green">
            <Label VerticalAlignment="Center" Content="提示:" />
            <TextBox Name="TBoxInfo" Background="Transparent" Height="30" Width="60" VerticalContentAlignment="Center" VerticalAlignment="Center" />
        </StackPanel>
        <StackPanel Margin="10" Grid.Column="2" Orientation="Horizontal" Background="Gray">
            <Label VerticalAlignment="Center" Content="总计:" />
            <TextBox Name="TBoxTotal" Background="Transparent" Height="30" Width="60" VerticalContentAlignment="Center" VerticalAlignment="Center">
                <TextBox.Text>
                    <MultiBinding Converter="{StaticResource AdditionConverter}">
                        <Binding ElementName="TBoxAlarm" Path="Text" />
                        <Binding ElementName="TBoxInfo" Path="Text" />
                    </MultiBinding>
                </TextBox.Text>
            </TextBox>
        </StackPanel>
    </Grid>

   这里,我们没有修改任何后台代码以及添加任何Model属性。这里这是举个简单的例子,说明MultiBinding和IMultiValueConverter怎样使用。

3.搞清楚路由事件的两种策略:隧道策略和冒泡策略

    WPF中元素的事件响由事件路由控制,事件的路由分两种策略:隧道和冒泡策略。要解释着两种策略,太多文字都是废话,直接上图分析:

image

    上图中包含三个Grid,它们的逻辑树层次关系由上到下依次为Grid1->Grid1_1->Grid1_1_1。例如我们鼠标左键单击Grid1_1_1,隧道策略规定事件的传递方向由树的最上层往下传递,在上图中传递的顺序为Grid1->Grid1_1->Grid1_1_1。而路由策略规定事件的传递方向由逻辑树的最下层往上传递,就像冒泡一样,从最下面一层一层往上冒泡,传递的顺序为Grid1_1_1->Grid1_1->Grid1。如果体现在代码上,隧道策略和冒泡策略有特征?

    (1)元素的事件一般都是隧道和冒泡成对存在,隧道事件包含前缀Preiview,而冒泡事件不包含Preview。就拿鼠标左键单击事件举例,隧道事件为PreviewMouseLeftButtonDown,而冒泡事件为MouseLeftButtonDown。

    (1)隧道策略事件先被触发,隧道策略触发完后才触发冒泡策略事件。例如单击Grid1_1_1,整个路由事件的触发顺序为:Grid1(隧道)->Grid1_1(隧道)->Grid1_1_1(隧道)->Grid1_1_1(冒泡)->Grid1_1(冒泡)->Grid1(冒泡)。

    (3)路由事件的EventArgs为RoutedEventArgs,包含Handle属性。如果在触发顺序的某个事件上设置了Handle等于true,那么它之后的隧道事件和冒泡事件都不会触发了。

    接下来我们就写例子分析,先看看界面的代码:

<Grid Width="400" Height="400" Background="Green" Name="Grid1" PreviewMouseLeftButtonDown="Grid1_PreviewMouseLeftButtonDown" MouseLeftButtonDown="Grid1_MouseLeftButtonDown">
        <Grid Width="200" Height="200" Background="Red" Name="Grid1_1" PreviewMouseLeftButtonDown="Grid1_1_PreviewMouseLeftButtonDown" MouseLeftButtonDown="Grid1_1_MouseLeftButtonDown">
            <Grid Width="100" Height="100" Background="Yellow" Name="Grid1_1_1" PreviewMouseLeftButtonDown="Grid1_1_1_PreviewMouseLeftButtonDown" MouseLeftButtonDown="Grid1_1_1_MouseLeftButtonDown">
            </Grid>
        </Grid>
    </Grid>

    我们为每个Grid都配置了隧道事件PreviewMouseLeftButtonDown,冒泡事件MouseLeftButtonDown。然后分别实现事件内容:

public partial class RoutedEventWindow : Window
    {
        public RoutedEventWindow()
        {
            InitializeComponent();
        }
        #region 隧道策略
        private void Grid1_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
        {
            PrintEventLog(sender, e, "隧道");
            e.Handled = true;
        }

        private void Grid1_1_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
        {
            PrintEventLog(sender, e, "隧道");
        }

        private void Grid1_1_1_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
        {
            PrintEventLog(sender, e, "隧道");
        }

        #endregion

        private void PrintEventLog(object sender, RoutedEventArgs e, string action)
        {
            var host = sender as FrameworkElement;
            var source = e.Source as FrameworkElement;
            var orignalSource = e.OriginalSource as FrameworkElement;
            Console.WriteLine("策略:{3}, sender: {2}, Source: {0}, OriginalSource: {1}", source.Name, orignalSource.Name, host.Name, action);
        }

        #region 冒泡策略

        private void Grid1_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
        {
            PrintEventLog(sender, e, "冒泡策略");
        }

        private void Grid1_1_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
        {
            PrintEventLog(sender, e, "冒泡策略");
        }

        private void Grid1_1_1_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
        {
            PrintEventLog(sender, e, "冒泡策略");
        }

        #endregion
    }

    事件的代码很简单,都调用了PrintEventLog方法,打印每个事件当前触发元素(sender)以及触发源(e.Source)和原始源(e.OriganlSource)。现在我们就把鼠标移到Grid1_1_1上面(黄色),单击鼠标鼠标左键。打印的日志结果为:

策略:隧道, sender: Grid1, Source: Grid1_1_1, OriginalSource: Grid1_1_1
策略:隧道, sender: Grid1_1, Source: Grid1_1_1, OriginalSource: Grid1_1_1
策略:隧道, sender: Grid1_1_1, Source: Grid1_1_1, OriginalSource: Grid1_1_1
策略:冒泡策略, sender: Grid1_1_1, Source: Grid1_1_1, OriginalSource: Grid1_1_1
策略:冒泡策略, sender: Grid1_1, Source: Grid1_1_1, OriginalSource: Grid1_1_1
策略:冒泡策略, sender: Grid1, Source: Grid1_1_1, OriginalSource: Grid1_1_1

    分析打印的日志是不是和我们上面描述的特征一样,先触发隧道事件(Grid1->Grid1_1->Grid1_1_1),然后再触发冒泡事件(Grid1_1_1->Grid1_1->Grid1),并且和我们描述的事件传递方向也一致?如果还不信我们可以跟进一步测试,我们知道最后一个被触发的隧道事件是Grid1_1_1上的Grid1_1_1_PreviewMouseLeftButtonDown,如果我在该事件上设置e.Handle等于true,按理来说,后面的3个冒泡事件都不会触发。代码如下:

private void Grid1_1_1_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
        {
            PrintEventLog(sender, e, "隧道");
            e.Handled = true;
        }

   输出结果如下:

策略:隧道, sender: Grid1, Source: Grid1_1_1, OriginalSource: Grid1_1_1
策略:隧道, sender: Grid1_1, Source: Grid1_1_1, OriginalSource: Grid1_1_1
策略:隧道, sender: Grid1_1_1, Source: Grid1_1_1, OriginalSource: Grid1_1_1

    结果和我们之前描的推理一致,后面的3个冒泡事件确实没有触发。弄清楚路由事件的机制非常重要,因为在很多时候我们会遇到莫名其妙的问题,例如我们为ListBoxItem添加MouseRightButtonDown事件,但始终没有被触发。究其原因,正是因为ListBox自己处理了该事件,设置Handle等于true。那么,ListBox包含的元素的MouseRightButtonDown肯定不会被触发。

4.Conveter比你想象的强大

    什么时候会用到Converter?做WPF的开发人员经常会遇到Enable状态转换Visibility状态、字符串转Enable状态、枚举转换字符串状态等。我们一般都知道IValueConverter接口,实现该接口可以很容易的处理上面这些情况。下面是Enable转Visibility代码:

public class EnableToVisibilityConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            if(value == null || !(value is bool))
            {
                throw new ArgumentNullException("value");
            }
            var result = (bool)value;

            return result ? Visibility.Visible : Visibility.Collapsed;
        }

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

    另外一种情况,例如我们在列表中显示某些数据,如果我们想给这些数据加上单位,方法有很多,但用converter实现的应该比较少见。这种情况我们可以充分利用IValueConverter的Convert方法的parameter参数。实现代码如下:

/// <param name="value">数字</param>
 /// <param name="targetType"></param>
/// <param name="parameter">单位</param>
/// <param name="culture"></param>
 /// <returns></returns>
  public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
 {
            if(value == null)
            {
                return string.Empty;
            }
            if(parameter == null)
            {
                return value;
            }
            return value.ToString() + " " + parameter.ToString();
}

    界面代码如下:

<TextBlock Text="{Binding Digit, Converter={StaticResource UnitConverter}, ConverterParameter=\'Kg\'}"></TextBlock>

    稍微复杂的情况,如果需要根据2个甚至多个值转换为一个结果。例如我们需要根据一个人的身高、存款、颜值,输出高富帅、一般、矮穷矬3个状态。这种情况IValueConverter已不再满足我们的要求,但Converter给我们提供了IMultiValueConverter接口。该接口可同时接收多个参数。按照前面的需求实现Converter,代码如下:

public class PersonalStatusConverter : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            //values:身高、颜值、存款
            if (values == null || values.Length != 3)
            {
                throw new ArgumentException("values");
            }
            int height; //身高
            int faceScore; //颜值
            decimal savings; //存款

            try
            {
                if(int.TryParse(values[0].ToString(), out height) && 
                   int.TryParse(values[1].ToString(), out faceScore) &&
                   decimal.TryParse(values[2].ToString(), out savings))
                {
                    //高富帅条件:身高180 CM以上、颜值9分以上、存款1000万以上
                    if(height >=180 && faceScore >=9 && savings >= 10000 * 1000)
                    {
                        return "高富帅";
                    }
                    //矮穷矬条件:身高不高于10CM,颜值小于1,存款不多于1元
                    if(height <= 10 && faceScore <= 1 && savings <= 1)
                    {
                        return "矮穷矬";
                    }
                }
            }
            catch (Exception ex){}

            return "身份未知";
        }

        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }

    实现了IMultiValueConverter接口后,我们还得知道怎样使用。这里我们还得配合MultiBinding来实现绑定多个参数。下面就根据一个测试例子看看如何使用它,代码如下:

<Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="5" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>
        <StackPanel VerticalAlignment="Center" Orientation="Horizontal" Grid.Row="0">
            <Label Content="身高(CM):" />
            <TextBox  Name="TBoxHeight"  Width="60"/>
            <Label Content="颜值(0-10):" />
            <TextBox Name="TBoxScore" Width="60" />
            <Label Content="存款(元):" />
            <TextBox Name="TBoxSaving" Width="100" />
        </StackPanel>
        <StackPanel Grid.Row="2" Orientation="Horizontal" VerticalAlignment="Center">
            <Label Content="测试结果:" />
            <TextBox IsReadOnly="True" Width="150">
                <TextBox.Text>
                    <MultiBinding Converter="{StaticResource PersonalStatusConverter}">
                 

以上是关于准备.Net转前端开发-WPF界面框架那些事,值得珍藏的8个问题的主要内容,如果未能解决你的问题,请参考以下文章

准备.Net转前端开发-WPF界面框架那些事,搭建基础框架

.NET6: 开发基于WPF的摩登三维工业软件

JavaScript与C#的互操作示例

vue调用接口那些事

前端开发和其他类别工程师配合的那些事!

接口自动化测试平台开发那些事2(架构)