65.9K
CodeProject 正在变化。 阅读更多。
Home

WPF 3D Tab Carousel

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (105投票s)

2010年1月1日

CPOL

9分钟阅读

viewsIcon

231257

downloadIcon

8929

如何使用WPF创建一个3D标签轮播。

引言

本文讨论如何为WPF创建一个3D标签控件。它将介绍3D旋转和摄像机计算,以及如何在一个较小的UI可视化控件集合中维护大量的控件。尽管本文大部分代码与创建3D网格有关,但我不会详细介绍,而是尽量将文章重点放在与本文更相关的其他方面。

这里有一个YouTube视频展示了部分已实现的功能;
http://www.youtube.com/watch?v=zmDHfsRENug&feature=plcp 

背景

这篇文章源于我为Sacha定制的一个解决方案。我最初粗略地做了一个简单的原型,Sacha希望它能做一些其他事情(比如滑动窗口,如下文所述),而我认为有些事情会很酷。最终,它变成了一个相当不错的控件。

使用代码

非常简单,下载源代码项目并构建它,Bornander.UI.TabCarousel项目包含一个名为Carousel的用户控件,它几乎处理了所有事情。

为方便演示,MainWindow.xaml.cs文件在其构造函数中有两个部分,可以一次使用一个来测试控件的不同方面。

要求

在实现此控件时,我最初有一套需求

  • 控件必须在FrameworkElement集合上工作,以便几乎任何UI元素都可以用作标签页。
  • 不同的FrameworkElement应该显示在放置在虚拟轮播中的3D面板上。
  • 必须通过下一个上一个转到特定索引来在面板之间导航。
  • 3D面板的数量可以少于实际的FrameworkElement(这是Sacha的要求之一,结果却是个麻烦事)。
  • 从一个标签页到另一个标签页的过渡必须通过动画实现。
  • 摄像机必须尝试在3D空间中定位自身,以尽可能地保持FrameworkElement的所需大小。

实现

概述

解决方案分为三个项目

  • Bornander.UI.TabCarousel,这是包含实际用户控件的项目。
  • Bornander.UI.TabCarousel.Test,这只是一个展示用户控件的测试项目。
  • Bornander.Wpf.Meshes,这是我正在开发的一个大型项目的提取,旨在简化WPF中的3D。

解决需求

使用FrameworkElement

这听起来很简单。我实现了一个名为Tab的类来封装标签页,它有一个名为Element的属性,可以用来将任何FrameworkElement设置为该Tab的视觉元素。

public FrameworkElement Element
{
    get { return element; }
    set
    {
      element = value;
      front.Visual = element;
    }
}

front私有成员是一个Viewport2DVisual3D

创建3D面板

我希望标签页是块状的,正面显示FrameworkElement;这很容易通过Viewport2DVisual3D类实现,但由于我也希望块有深度,所以我必须创建两个网格,每个网格都有自己的材质。

首先,我创建了一个无盖的盒子,全部在一个网格中,并使用简单的DiffuseMaterial

这是通过从Bornander.Wpf.Meshes创建Box来实现的,并指定除了正面之外的所有面都应包含在内。

boxMesh = Box.CreateBoxMesh(1, 1, depth, 
      Box.Side.Right | 
      Box.Side.Left | 
      Box.Side.Top | 
      Box.Side.Bottom | 
      Box.Side.Back);

请注意,盒子的宽度和高度设置为1.0,这是因为正确的宽高比(即UI元素设计时的比例)在分配FrameworkElement之前不会计算,然后会计算一个缩放变换来实现这一点。

然后,盒子“盖子”以相同的方式创建,但这次只包含正面。

visualHostMaterial = new DiffuseMaterial(Brushes.White);
visualHostMaterial.SetValue(
Viewport2DVisual3D.IsVisualHostMaterialProperty, true);

visualMesh = Box.CreateBoxMesh(1, 1, depth, Box.Side.Front);

front = new Viewport2DVisual3D
{
    Geometry = visualMesh,
    Visual = element,
    Material = visualHostMaterial
};

视觉宿主材质用于将UIElement显示为3D表面上的交互式材质。然后,这两个网格被添加到ModelVisual3D类型的模型中;这样,每当我需要移动、旋转或缩放网格时,我都可以简单地将变换应用于该网格组,而无需单独为每个网格进行操作。

整个Tab类如下所示

class Tab
{
    private readonly Material visualHostMaterial;
    private readonly MeshGeometry3D boxMesh;
    private readonly MeshGeometry3D visualMesh;

    private Viewport2DVisual3D front;
    private ModelVisual3D back;

    private FrameworkElement element;

    private double depth;

    public ModelVisual3D Model { get; private set; }

    public Tab(FrameworkElement element, Color color, double depth)
    {
      this.element = element;
      this.depth = depth;

      visualHostMaterial = new DiffuseMaterial(Brushes.White);
      visualHostMaterial.SetValue(
        Viewport2DVisual3D.IsVisualHostMaterialProperty, true);

      boxMesh = Box.CreateBoxMesh(1, 1, depth, 
        Box.Side.Right | 
        Box.Side.Left | 
        Box.Side.Top | 
        Box.Side.Bottom | 
        Box.Side.Back);
      visualMesh = Box.CreateBoxMesh(1, 1, depth, Box.Side.Front);


      front = new Viewport2DVisual3D
      {
        Geometry = visualMesh,
        Visual = element,
        Material = visualHostMaterial
      };


      back = new ModelVisual3D
      {
        Content = new GeometryModel3D
        {
          Geometry = boxMesh,
          Material = new DiffuseMaterial(Brushes.CadetBlue),
        }
      };

      Model = new ModelVisual3D();

      Model.Children.Add(back);
      Model.Children.Add(front);
    }

    public void UpdateTransform(int index, double angle, double radius)
    {
      TranslateTransform3D translaslation = new TranslateTransform3D(
        0, 0, radius - depth / 2.0);
      
      RotateTransform3D rotation = new RotateTransform3D(
        new AxisAngleRotation3D(new Vector3D(0, 1, 0), -index * angle));

      ScaleTransform3D scale = element != null ? 
        new ScaleTransform3D(1.0, double.IsNaN(element.Height) 
          ? 1.0 : 
            element.Height / element.Width, 1.0) 
          : new ScaleTransform3D(1, 1, 1);

      Transform3DGroup transform = new Transform3DGroup();

      transform.Children.Add(scale);
      transform.Children.Add(translaslation);
      transform.Children.Add(rotation);

      Model.Transform = transform;
    }

    public FrameworkElement Element
    {
      get { return element; }
      set
      {
        element = value;
        front.Visual = element;
      }
    }
}

允许动画导航

为了将Tab放置在“轮播”中,必须计算几个事项:不同3D面板之间的角度、面板的特定位置和半径,以及从假想中心到面板中心的距离。所有这些事项都是动态的,并且随着面板数量的变化而变化。

第一项,角度很容易;只需将360度除以标签面板的数量;这意味着如果有三个面板,它们应该相隔120度。第二项,一个标签的特定角度是使用索引计算的;Carousel用户控件维护一个IList<Tab>,并且角度是使用此列表中的索引计算的。Tab类可以自行计算,这正是UpdateTransform方法所做的。它基于角度和索引(只需将角度乘以索引)创建一个旋转变换,该变换将面板旋转到轮播中的正确位置。最后一部分是半径;随着面板数量的增加,半径需要越来越大,以防止它们重叠。由于需要知道面板的数量,因此必须由Carousel计算。

private static double DegreesToRadians(double degrees)
{
    return (degrees / 180.0) * Math.PI;
}

private double CalculateRadius()
{
    double splitAngle = 360.0 / tabs.Count;
    switch (tabs.Count)
    {
      case 1: return 0.0;
      case 2: return 0.25;
      default:
        return 1.0 / Math.Abs(Math.Sin(DegreesToRadians(splitAngle)));
    }
}

由于所有面板的宽度都是1.0(这从不改变;无论宽高比如何,我只修改高度),我将半径计算为1.0 / sin(面板之间的角度)。这不是最佳距离(即,不是不重叠的最小可能距离),但它保证大于该距离,而且我认为它会产生一个合适的距离。

为了实际从一个面板旋转到另一个面板,我不得不进行许多奇怪的计算(主要是由于Sacha不合理的需求,如滑动窗口和循环集合);代码量不多,但仍然相当令人困惑。Sacha想要一个转到功能,允许用户直接从一个标签页跳转到另一个标签页,这很容易实现,但他希望它永远不用旋转超过一步。也就是说,在标准设置下,从标签1跳转到4会经过2和3,然后到达4,但Sacha希望直接跳转到4。如果问我的话,这是完全不合理的。

下面是处理此问题的代码,但首先,值得注意的是,我通过排队SpinInstruction来请求旋转,这些指令告诉Animate方法从哪里到哪里。

private class SpinInstruction
{
    public int From { get; private set; }
    public int To { get; private set; }

    public SpinInstruction(int from, int to)
    {
      From = from;
      To = to;
    }
}

在标准设置下,每当用户请求多步旋转时,它就会被排队,成为构成该旋转的所有步骤。

private void Animate()
{
    // If no instructions are queue up
    // or if we're already animating, ignore request
    if (instructions.Count == 0 || isAnimating)
      return;

    // Grab the next spin instruction
    SpinInstruction instruction = instructions.Peek();
    bool wrapIt = false;
    
    // If the spin To target is outside the elements list, 
    // this is going to be a wrapping sping
    if (instruction.To < 0 || instruction.To >= elements.Count)
    {
      // If WrapAtEnd is enabled and if the instruction 
      // target is a valid one accept it
      if (WrapAtEnd && (instruction.To == -1 || 
                    instruction.To == elements.Count))
      {
        // Set wrapIt to true to indicate that this 
        // is a wrapping spin and then adjust the instruction to
        // fit the standard logic
        wrapIt = true;
        instruction = new SpinInstruction(
          instruction.From, 
          instruction.To < 0 ? elements.Count - 1 : 0);
      }
      else // Done animating for now, remove instruction and return
      {
        instructions.Dequeue();
        isAnimating = false;
        return;
      }
    }
    
    // Angle between panels
    double angle = 360.0 / tabs.Count;
    
    // Figure out the target index in the tabs list
    int tabToIndex = AlwaysOnlyOneStep ? 
      GetSafeIndex(currentTabIndex + 
          Math.Sign(instruction.To - instruction.From)) 
          : GetSafeIndex(instruction.To);

    // If this is a wrapping spin, the tabToIndex can 
    // be set to either the first or last index
    if (wrapIt)
    {
      if (instruction.To == 0)
        tabToIndex = 0;
      if (instruction.To == elements.Count - 1)
        tabToIndex = tabs.Count - 1;
    }

    // Unhook from visual tree if required because 
    // a Visual cannot have to parents
    foreach (Tab owner in (from tab in tabs 
      where tab.Element == elements[instruction.To] 
        || tab.Element == elements[instruction.From] select tab))
      owner.Element = null;

    // Make sure the current tab contains the From element
    tabs[currentTabIndex].Element = elements[instruction.From];
    tabs[currentTabIndex].UpdateTransform(currentTabIndex, 
                          angle, CalculateRadius());

    // Make sure the target tab contains the To element, 
    // this is what allows less tab panels than elements
    tabs[tabToIndex].Element = elements[instruction.To];
    tabs[tabToIndex].UpdateTransform(tabToIndex, angle, CalculateRadius());
    isAnimating = true;

    // The angles of the carousel for the from and to tabs
    double fromAngle = currentTabIndex * angle;
    double toAngle = tabToIndex * angle;

    // If this is a wrapping spin add/remove
    // a full lap otherwise the animation 
    // would run backwards for these cases
    if (wrapIt)
    {
      if (instruction.To == 0)
        toAngle += 360;
      if (instruction.To == elements.Count - 1)
        toAngle -= 360;
    }

    // If this is spinning to a later element, 
    // but the tab index is less than the current tab index, add a lap
    if (instruction.To - instruction.From > 0 && 
        tabToIndex < currentTabIndex)
      toAngle += 360;

    // If this is spinning to a earlier element,
    // but the tab index is greater than the 
    // current tab index, subtract a lap
    if (instruction.To - instruction.From < 0 && 
      tabToIndex > currentTabIndex)
      toAngle -= 360;

    CreateSpinAnimation(instruction, tabToIndex, fromAngle, toAngle);
}

CreateSpinAnimation负责创建实际的动画,并在旋转动画完成后再次调用Animate

旋转中,FlipIt标志设置为true。

计算摄像机距离

在上面的代码中,摄像机距离是逐个标签计算的。这是因为虽然标签本身会缩放到正确的宽高比,但屏幕上的大小也是一个问题。例如,如果一个用户控件被设计为在300x400的尺寸显示,那么创建一个300宽、400高的3D盒子是不够的,因为一组单位(第一组)是像素,而第二组是无单位的。它只是3D的距离,而不是像素。因此,Carousel必须计算摄像机必须达到的与面板的距离,以便正确渲染UI元素。这还取决于包含所有元素的Viewport3D的大小。

基本上,它看起来是这样的

用数学术语来说:求解距离y,其中y是由y本身、0.5(3D面板宽度的一半)构成的直角三角形的一条边,斜边由摄像机的视场(或半视场)延伸形成。由于我们不知道斜边的长度,但可以确定角度(因为它是视场的一半),所以我们可以使用tan(field of view / 2.0),或者用代码表示

private double CalculateCameraDistance(int index, int tabIndex)
{
    Tab tab = tabs[tabIndex];

    double y = 0.5 / Math.Tan(DegreesToRadians(MainCamera.FieldOfView / 2.0));

    double panelWidth = tab.Element != null ? tab.Element.Width : 1.0;
    double ratio = Grid3D.ActualWidth / panelWidth;

    return CalculateRadius() + Math.Max(ratio, 1.0) * y;
}

找到y后,将其乘以设计UI元素宽度与Viewport3D当前宽度之间的比率,以补偿Viewport3D的大小。最后,将其偏移到轮播半径的距离。通过取1.0和计算出的比率的最大值,Math.Max(ratio, 1.0),距离将确保面板的整个宽度始终可见,即使Viewport3D小于面板的设计尺寸。

由于大多数WPF用户控件设计用于在窗口或其他控件中使用,因此其宽度和高度并不总是可以确定(因此需要WPF UI元素上同时存在的WidthActualWidth属性)。为了让用户控件与此标签控件配合使用,因此在设计时设置MinWidthMaxWidthWidth很重要。

用户控件

实现轮播的WPF用户控件称为Carousel,很直观,不是吗?而且,由于此控件主要涉及旋转和摄像机位置计算,因此其XAML非常简单

<UserControl x:Class="Bornander.UI.TabCarousel.Carousel"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    SizeChanged="HandleSizeChanged">
    <Grid x:Name="Grid3D" Width="Auto" Height="Auto">
      <Viewport3D>
        <Viewport3D.Camera>
          <PerspectiveCamera x:Name="MainCamera" 
          FieldOfView="90" 
          Position="0,0,0" 
          LookDirection="0,0,-1"/>
        </Viewport3D.Camera>
        <ModelVisual3D>
          <ModelVisual3D.Content>
            <AmbientLight x:Name="Ambient" Color="#808080"/>
          </ModelVisual3D.Content>
        </ModelVisual3D>
        <ModelVisual3D>
          <ModelVisual3D.Content>
            <DirectionalLight x:Name="Directional" 
            Color="#FFFFFFFF" Direction="0,-1,-1"/>
          </ModelVisual3D.Content>
        </ModelVisual3D>
        <ModelVisual3D x:Name="CarouselContainer"/>
      </Viewport3D>
    </Grid>
</UserControl>

用户控件设置了一些东西

  • 摄像机;摄像机的位置必须是(0, 0, 0)才能正确进行距离计算;此外,视线方向必须沿着Z轴。
  • 环境光,这样不仅仅是被定向光照射到的表面是可见的。
  • 定向光;这很重要,因为没有它场景会显得“平坦”。
  • CarouselContainer:这只是用于容纳轮播中所有项目的ModelVisual3D;当轮播旋转时,这实际上是正在旋转的对象。

关注点

我也可以在XAML中定义网格,但我发现使用代码更简单、更灵活。最复杂的部分是正确处理循环旋转,尤其是在标签数量少于轮播中的元素数量时。这是因为旋转动画的工作方式,从270度到360度的动画与从270度到0度的动画不同,这在某种程度上是有道理的,但仍然让我头疼,因为360度和0度实际上是相同的。

一如既往,任何关于代码或文章的评论都非常欢迎。

历史

  • 2010-01-01:第一个版本。
© . All rights reserved.