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

《生活大爆炸》字幕查看器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (52投票s)

2011年12月28日

CPOL

10分钟阅读

viewsIcon

179440

downloadIcon

1424

了解如何下载您最喜爱的电视剧剧本并在智能手机上进行格式化。

目录

引言

到目前为止,我玩我的第一部智能手机已经好几个月了,我终于意识到它在快速轻松地从网上阅读内容方面有多大的潜力。如今,许多网站都提供移动友好的功能(例如,您可以打开“移动”开关阅读Code Project文章)。因此,您最终会得到一个更简洁、更快的文章版本,更适合智能手机的外形,而不是那些为19英寸PC屏幕设计的笨重、令人困惑的完整网页。

但即使有移动版本选项,我也不总是对结果满意,这取决于我正在阅读的内容类型。我举个例子:我喜欢看电视节目,作为一名非英语母语者,我经常会听不懂很多笑话。所以我喜欢阅读电视剧剧本,特别是这个特定博客的剧本。但是,如果在看电视节目时我错过了笑话,那么阅读纯文本的电视剧剧本应该更有趣。为什么不将对话格式化为对话线索,配上角色图片和对话气泡呢?这个想法催生了一个我在Windows Phone应用商店发布的免费应用程序,最后,还有这篇关于The Code Project的文章。

系统要求

要使用本文提供的《生活大爆炸》剧本,您必须安装Windows Phone SDK 7.1,您可以直接从微软100%免费下载。

免责声明

对于那些可能对版权问题有所顾虑的人:我在这里所做的只是简单地阅读和格式化这个特定博客已提供的内容。与任何网络浏览器应用程序一样,此应用程序只是一个阅读器,上传到本文的源代码项目不提供任何文本内容。

WebClient组件:从Web下载内容

WebClient Silverlight组件是Windows Phone平台中网络内容消费技术的核心。在我们的项目中,我们使用DownloadStringAsync函数从博客页面检索原始内容。

函数名不言自明:DownloadStringAsync以异步方式下载字符串内容。

WebClient.DownloadStringAsync方法 (Uri)

将指定URI处的资源下载为字符串。
  • 命名空间System.Net
  • 程序集:System.Net (在 System.Net.dll 中)
语法
public void DownloadStringAsync(
    Uri address
)
参数
  • address:类型:System.Uri。要下载的资源位置。

此外,值得一提的是,DownloadStringAsync方法不以传统的指令序列工作。也就是说,DownloadStringAsync后面的指令将在Web请求提交后立即执行:它不会等待内容下载完成,所以不要期望处理会按照代码指令的相同顺序发生。

使用DownloadStringAsync函数的正确方法与您应该应用于任何其他异步请求的方法相同:您必须订阅WebClient类提供的事件,这样您就可以控制事情发生的顺序。DownloadStringCompleted事件订阅特别重要,因为它让您有机会告诉程序在内容完全下载后立即做什么。另一个有用的事件是DownloadProgressChanged,它通常用于向用户提供有关下载进度的视觉反馈。

private void DownloadString(string url, 
Action<string> onDownloadCompleted, 
Action onConnectionFailed, 
Action<int> onProgressChanged)
{
    try
    {
        var webClient = new WebClient();
        webClient.DownloadStringCompleted += (sender, e) =>
            {
                try
                {
                    onDownloadCompleted(e.Result);
                }
                catch (Exception exc)
                {
                    MessageBox.Show(@"We're sorry, but an unexpected 
                    connection error occurred. Please try again later.", 
                    "Oops!", MessageBoxButton.OK);
                    onConnectionFailed();
                }
            };
        webClient.DownloadProgressChanged += (sender, e) =>
            {
                onProgressChanged(e.ProgressPercentage);
            };
        webClient.AllowReadStreamBuffering = true;
        webClient.DownloadStringAsync(new Uri(url), webClient);
    }
    catch (Exception exc)
    {
        MessageBox.Show(@"We're sorry, but an unexpected 
        connection error occurred. Please try again later.", 
        "Oops!", MessageBoxButton.OK);
        onConnectionFailed();
    }
}

下载剧集列表

我们应用程序的第一个相关操作是下载剧集列表。您可能会认为紧接着我们将开始下载所有剧集,但这是不正确的:一旦剧集列表下载完成,程序将等待用户打开每个特定的剧集才开始下载其剧本。之所以如此,是因为许多用户(包括我)可能受到运营商合同限制,一次性下载所有剧集会不必要地增加运营商使用量。我确信许多人(包括我自己)会更喜欢在WiFi网络下下载剧集。

剧集列表嵌入在主博客页面的HTML中,因此我们必须使用正则表达式从中提取相关数据。请记住,我们使用异步下载方法,因此一旦剧集列表下载完成,我们就会执行onContentReady回调。同样,我们还有两个回调,一个用于报告下载进度,一个用于连接失败事件。

public void DownloadEpisodeList(Action<List<Episode>> onContentReady, 
        Action onConnectionFailed, Action<int> onProgressChanged)
{
    if (!InternetIsAvailable())
        onConnectionFailed();

    DownloadString("http://bigbangtrans.wordpress.com/",
        (content) =>
        {
            var episodes = new List<Episode>();

            var linkRegex = new Regex("<a[^>]+
            href\\s*=\\s*[\"\\'](?!(?:#|javascript\\s*:))([^\"\\']+)
            [^>]*>(.*?)<\\/a>");
            MatchCollection matches = linkRegex.Matches(content);

            var episodeId = 1;
            for (var i = 0; i < matches.Count; i++)
            {
                var match = matches[i];
                var linkValue = match.Groups[1].Value;
                var nameValue = match.Groups[2].Value.Replace(" ", " ");

                var nameRegex = new Regex("^Series ([0-9]) Episode ([0-9][0-9]) – (.*)");
                if (nameRegex.IsMatch(nameValue))
                {
                    var season = int.Parse(nameRegex.Matches(nameValue)[0].Groups[1].Value);
                    var number = int.Parse(nameRegex.Matches(nameValue)[0].Groups[2].Value);
                    var name = nameRegex.Matches(nameValue)[0].Groups[3].Value;
                    if (nameRegex.IsMatch(nameValue))
                    {
                        episodes.Add(new Episode()
                        {
                            Id = episodeId,
                            Number = number,
                            Description = "",
                            Link = linkValue,
                            Name = name,
                            Season = season,
                            CreatedOn = DateTime.Now
                        });
                    }
                }
                episodeId++;
            }

            onContentReady(episodes);
        },
        () =>
        {
            onConnectionFailed();
        },
        (percentage) => {
            onProgressChanged(percentage);
        });
}

本地保存剧集列表

该应用程序依赖于序列化/反序列化对象的旧技术来持久化/检索与剧集列表、剧集等相关的XML数据。确实还有其他有趣的方法可以很好地完成这项工作(例如本地SQL数据库),但考虑到此应用程序需求的简单性,XML范式到目前为止运行良好,我目前不打算更改它,至少在短期内。

如果您是一位经验丰富的Silverlight开发人员,那么不用说我们在这里使用的是隔离存储。如果您不是,那么隔离存储是一个封闭存储系统,或沙盒系统,其中应用程序只被授予访问它已创建的文件/文件夹的权限。

我们方法另一个相关方面是,我们不从XML文档生成XML。相反,我们实例化在模型中定义的POCO(纯旧C#对象),然后将它们序列化为XML文件。我发现这种方法比直接处理XML文档更灵活、更不繁琐。此外,它使我们能够进行进一步的重构和类解耦,这始终是一个良好的设计实践。

每当应用程序启动时,它都会尝试下载新的、更新的剧集列表版本,如果成功则将其本地保存。但如果下载失败,我们可以离线工作并使用上次下载的剧集列表。如果只下载一次剧集列表,那会很棒,但博客作者仍在更新剧集列表(希望在未来许多年里会继续更新!),所以我们必须不断检查更新。

public static void SaveEpisodeList(List<Episode> episodes)
{
    StreamResourceInfo streamResourceInfo = 
      Application.GetResourceStream(new Uri(EPISODELIST_FILE_PATH, UriKind.Relative));

    using (IsolatedStorageFile isolatedStorage = 
                IsolatedStorageFile.GetUserStoreForApplication())
    {
        string directoryName = System.IO.Path.GetDirectoryName(EPISODELIST_FILE_PATH);
        if (!string.IsNullOrEmpty(directoryName) && 
               !isolatedStorage.DirectoryExists(directoryName))
        {
            isolatedStorage.CreateDirectory(directoryName);
        }

        isolatedStorage.DeleteFile(EPISODELIST_FILE_PATH);
        Serialize(isolatedStorage, EPISODELIST_FILE_PATH, episodes, typeof(List<Episode>));
    }
}

下载剧集剧本

每集都在博客中拥有自己的页面。请记住,剧集页面的URL是在上一步检索剧集列表时发现并本地存储的。

与剧集列表的情况类似,剧集剧本与页面其余的HTML混杂在一起,我们再次需要从中去粗取精。我们应用更多正则表达式来提取相关数据,然后用剧集数据填充EpisodeTranscript实例。

public void DownloadEpisodeTranscript(Episode episode, 
    Action<EpisodeTranscript> onContentReady, 
    Action onConnectionFailed, Action<int> onProgressChanged)
{
    if (!InternetIsAvailable())
        onConnectionFailed();

    DownloadString(episode.Link,
        (content) =>
        {
            var episodeTranscript = new EpisodeTranscript()
            {

                Number = episode.Number,
                Description = episode.Description,
                Link = episode.Link,
                Name = episode.Name,
                Season = episode.Season,
            };

            episodeTranscript.Quotes = new List<Quote>();

            var linkRegex = new Regex("(<p>|<span 
            style=\"font-size:small;font-family:Calibri;\">|<span 
            style=\"font-family:Calibri;\">)(.*?)(<\\/p>|<\\/span>)");
            MatchCollection matches = linkRegex.Matches(content);
                
            for (var i = 0; i < matches.Count; i++)
            {
                var match = matches[i];
                var quoteValue = match.Groups[2].Value;

                quoteValue = quoteValue.Replace("<span>", "");
                quoteValue = quoteValue.Replace("</span>", "");
                quoteValue = quoteValue.Replace("<em>", "");
                quoteValue = quoteValue.Replace("</em>", "");
                quoteValue = quoteValue.Replace("…", "...");
                quoteValue = quoteValue.Replace("’", "'");
                quoteValue = quoteValue.Replace("&", "&");

                var quoteRegex = new Regex("(.*):(.*)");
                MatchCollection matches2 = quoteRegex.Matches(quoteValue);

                var character = "";
                var speech = "";

                if (matches2.Count == 0)
                {
                    speech = quoteValue;
                }
                else
                {
                    var quoteMatch = matches2[0];
                    if (quoteMatch.Groups[1].Value.Contains("<img") ||
                        quoteMatch.Groups[1].Value.Contains("<a href"))
                        break;

                    character = (quoteMatch.Groups[1].Value + "(").Split('(')[0].Trim();
                    speech = quoteMatch.Groups[2].Value;
                }

                episodeTranscript.Quotes.Add(new Quote()
                {
                    Id = episodeTranscript.Quotes.Count() + 1,
                    Season = episode.Season,
                    Number = episode.Number,
                    Image = string.Format(@"/Images/{0}.png", character),
                    Character = character,
                    Speech = speech,
                    CreatedOn = DateTime.Now
                });
            }

            onContentReady(episodeTranscript);
        },
        () =>
        {
            onConnectionFailed();
        },
        (percentage) =>
        {
            onProgressChanged(percentage);
        });
}

本地保存剧集剧本

我们保存剧集剧本的方式与保存剧集列表的方式非常相似。不同之处在于我们持久化一个EpisodeTranscript实例。另一个区别是,一旦剧集剧本下载并本地持久化,它就不需要再次下载。这就是为什么每集都有自己独立存储的XML文件。

public static void SaveEpisodeTranscript(EpisodeTranscript episodeTranscript)
{
    var episodeTranscriptFilePath = string.Format(EPISODETRANSCRIPT_FILE_PATH, 
                                           episodeTranscript.Season, episodeTranscript.Number);
    StreamResourceInfo streamResourceInfo = 
      Application.GetResourceStream(new Uri(episodeTranscriptFilePath, UriKind.Relative));

    using (IsolatedStorageFile isolatedStorage = IsolatedStorageFile.GetUserStoreForApplication())
    {
        string directoryName = System.IO.Path.GetDirectoryName(episodeTranscriptFilePath);
        if (!string.IsNullOrEmpty(directoryName) && 
                  !isolatedStorage.DirectoryExists(directoryName))
        {
            isolatedStorage.CreateDirectory(directoryName);
        }

        isolatedStorage.DeleteFile(episodeTranscriptFilePath);
        Serialize(isolatedStorage, episodeTranscriptFilePath, 
                  episodeTranscript, typeof(EpisodeTranscript));
    }
}

搜索剧集

这部电视剧在巴西非常受欢迎,我和我的朋友们都喜欢讨论其中的情境、笑话和对话。有时很难记住是哪一季、哪一集,以及谁说了什么。于是,对我来说很明显,应用程序最好有一个搜索功能。这显然没什么大不了的,与我们迄今所做的工作相比,只需在每集剧本中搜索特定文本相对容易。但确实,这是一个很棒又有趣的功能。

由于剧集是单独持久化在隔离存储中的,并且其内容未以任何方式索引,我们除了逐集检索并搜索其内容之外,没有其他简单方法。因此,下载的剧集越多,整个搜索操作完成所需的时间就越长。这就是为什么我们创建一个搜索功能,它会报告进度并在搜索过程中保持响应。如果您正在寻找特定文本,并且它在第一季的第三集中找到,它会迅速出现在搜索结果列表中,但搜索操作将继续进行。尽管如此,您仍然可以点击已找到的结果并导航到相应的剧集,而无需等待搜索功能完成其工作。

public static void SearchQuotes(string searchText, 
Action<int> onProgressChanged, 
Action<Quote> onNewQuotesFound, 
Action onSearchCompleted)
{
    searchText = searchText.ToLower();
    List<Quote> quotes = new List<Quote>();

    var episodes = EpisodeRepository.RetrieveEpisodeList();
    var episodeCount = episodes.Count();
    var currentEpisodePosition = 0;

    foreach (var episode in episodes)
    {
        currentEpisodePosition++;
        onProgressChanged((100 * currentEpisodePosition) / episodeCount);
        if (EpisodeRepository.EpisodeTranscriptExists(episode.Season, 
        episode.Number))
        {
            var episodeTranscript = EpisodeRepository
            .RetrieveEpisodeTranscript(episode.Season, episode.Number);

            var matches = episodeTranscript.Quotes.Where(x => 
            x.Character.ToLower().Contains(searchText.ToLower()) ||
                x.Speech.ToLower().Contains(searchText.ToLower()));

            foreach (var match in matches)
            {
                onNewQuotesFound(match);
            }
        }
    }
    onProgressChanged(100);
    onSearchCompleted();
}

发布到Facebook和Twitter

在您的Windows Phone上阅读您最喜欢的电视剧剧本可能很酷,但除非您的家人、朋友和同事也下载该应用程序,否则您将不得不通过对话与他们分享这些内容。幸运的是,有一种更有效的方式可以通过Facebook和Twitter等社交网络分享,我们将利用Windows Phone内置的本地分享工具。

要分享特定的引语,您必须选择并双击对话气泡或一组气泡,然后您将被重定向到分享页面。请记住,由于您要发布到的社交网络的性质,可能会对最大字符数有所限制。

分享后,选定的引语将连同应用程序在Windows Phone Marketplace上的链接一起发布。

private void EpisodeTranscriptListBox_DoubleTap(object sender, 
    System.Windows.Input.GestureEventArgs e)
{
    var selectedItems = EpisodeTranscriptListBox.SelectedItems;
    if (selectedItems != null)
    {
        ShareLinkTask shareLinkTask = new ShareLinkTask();
        shareLinkTask.LinkUri = 
        new Uri("http://windowsphone.com/s?appid=e28e5cab-a604-4913-af5c-5694923433b6", 
        UriKind.Absolute);
        shareLinkTask.Title = "The Big Bang Transcripts Viewer";
        StringBuilder sb = new StringBuilder();
        foreach (var selectedItem in selectedItems)
        {
            var quote = (Quote)selectedItem;
            sb.AppendFormat("{0}: {1}\r\n", quote.Character, quote.Speech.Trim());
        }
        shareLinkTask.Message = string.Format("{0}\r\n({1})", sb.ToString(), txtEpisodeName.Text);
        shareLinkTask.Show();
    }
}

显示对话

对话是应用程序最重要的部分。它们是应用程序存在的根本原因。考虑到这一点,我尝试以一种清晰有趣的方式展示它们。我的想法是,每个引语都有一个单独的彩色对话气泡,并配有角色的照片。此外,我认为以交替的方式显示对话会很好,即对话方向从左到右,然后再从右到左交替,这样会类似于短信和其他移动消息应用程序中的体验。

话虽如此,对话的显示是值得详细讨论的问题。首先,我们有原生且功能强大的ListBox Silverlight控件,它允许我们为要列出的项目创建模板。

我们为Listbox控件提供的模板有两种基本行为:它可以显示系列“评论”(如剧集简介、片头字幕序列部分等)或对话本身。评论显示在横幅中,而对话则是带有角色照片的气泡。这种区分使阅读更有趣,更不容易混淆。

Image
描述
IsComment属性转换为Visibility,因此如果该行是评论行,则评论模板将变为可见。评论内容将以斜体显示在样式化的旗帜状矩形中。
XAML
<Grid Visibility="{Binding IsComment, Converter={StaticResource 
    booleanToVisibilityConverter}}" Margin="10">
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="40"/>
        <ColumnDefinition Width="360"/>
        <ColumnDefinition Width="40"/>
    </Grid.ColumnDefinitions>
    <Grid.RowDefinitions>
        <RowDefinition Height="2"/>
        <RowDefinition/>
        <RowDefinition Height="10"/>
    </Grid.RowDefinitions>
    <Border Grid.Row="1" Grid.RowSpan="2" Grid.Column="0" 
    Background="{Binding Id, Converter={StaticResource 
    quoteIdToBaloonBrushConverter}}" 
    CornerRadius="0,0,5,0" Margin="0,0,1,0"/>
    <Border Grid.Row="1" Grid.RowSpan="2" Grid.Column="2" 
    Background="{Binding Id, Converter={StaticResource 
    quoteIdToBaloonBrushConverter}}" 
    CornerRadius="0,0,0,5" Margin="1,0,0,0"/>
    <Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0"
     Grid.ColumnSpan="3" BorderBrush="{StaticResource 
     PhoneBackgroundBrush}" 
     BorderThickness="1" Background="{Binding Id, 
     Converter={StaticResource 
     quoteIdToBaloonBrushConverter}}" 
     CornerRadius="10,10,0,0" Margin="10">
        <TextBlock Text="{Binding Speech}" FontSize="30" 
        FontStyle="Italic" TextWrapping="Wrap" Margin="4" 
        Foreground="{Binding Id, Converter={StaticResource 
        quoteIdToFontBrushConverter}}" ></TextBlock>
    </Border>
    <Path Width="40" Height="10" Grid.Column="0" 
    Grid.ColumnSpan="3" Grid.Row="0" 
    Grid.RowSpan="3" Fill="{StaticResource PhoneBackgroundBrush}" 
    Data="M0,0 L1,0 L1,1 L0,0" Stretch="Fill" 
    HorizontalAlignment="Left" VerticalAlignment="Bottom" 
    Margin="10,0,0,0"/>
    <Path Width="40" Height="10" Grid.Column="0" 
    Grid.ColumnSpan="3" Grid.Row="0" 
    Grid.RowSpan="3" Fill="{StaticResource PhoneBackgroundBrush}" 
    Data="M1,0 L0,0 L0,1 L1,0" Stretch="Fill" 
    HorizontalAlignment="Right" VerticalAlignment="Bottom" 
    Margin="0,0,10,0"/>
</Grid>


Image
描述
IsSpeech属性转换为Visibility,因此如果该行是对话行,则对话模板将变为可见。对话内容将显示在对话气泡中,紧邻角色图片。quoteIdToPictureColumnConverterquoteIdToBalloonColumnConverter转换器获取引语的ID属性,并根据奇偶校验值(即ID是偶数还是奇数)返回气泡对话矩形和角色图片的正确网格列值。简而言之,当对话行为偶数时,气泡显示在左侧,角色图片显示在右侧。
XAML
<Grid HorizontalAlignment="Stretch" 
        Visibility="{Binding IsSpeech, Converter={StaticResource 
        booleanToVisibilityConverter}}">
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="100"/>
        <ColumnDefinition Width="250"/>
        <ColumnDefinition Width="100"/>
    </Grid.ColumnDefinitions>
    <StackPanel Grid.Column="{Binding Id, 
    Converter={StaticResource quoteIdToPictureColumnConverter}}" 
    VerticalAlignment="Center">
        <Image Source="{Binding Image}" Height="100" 
        Width="100"></Image>
        <TextBlock Text="{Binding Character}"/>
    </StackPanel>
    <Grid Grid.Column="{Binding Id, Converter={StaticResource quoteIdToBalloonColumnConverter}}" 
              Grid.ColumnSpan="2" Margin="5,15,5,15">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="20"/>
            <ColumnDefinition/>
            <ColumnDefinition Width="20"/>
        </Grid.ColumnDefinitions>
        <Path Grid.Column="0" Visibility="{Binding Id, 
          Converter={StaticResource evenQuoteIdToVisibilityConverter}}" 
          Fill="{Binding Id, Converter={StaticResource 
          quoteIdToBaloonBrushConverter}}" 
          Data="M0,1 L1,0 L1,2 L0,1" Height="20" 
          Width="20" Stretch="Fill"/>
        <Rectangle Grid.Column="1" Fill="{Binding Id, 
          Converter={StaticResource quoteIdToBaloonBrushConverter}}" 
          RadiusX="4" RadiusY="4"/>
        <TextBlock Grid.Column="1" Text="{Binding Speech}" 
          FontSize="26" 
          TextWrapping="Wrap" Margin="4" 
          Foreground="{Binding Id, 
          Converter={StaticResource 
          quoteIdToFontBrushConverter}}"></TextBlock>
        <Path Grid.Column="2" Visibility="{Binding Id, 
          Converter={StaticResource oddQuoteIdToVisibilityConverter}}" 
          Fill="{Binding Id, Converter={StaticResource 
          quoteIdToBaloonBrushConverter}}" 
          Data="M1,1 L0,0 L0,2 L1,1" Height="20" Width="20" 
          Stretch="Fill"/>
    </Grid>
</Grid>


Image
描述
在这种情况下,quoteIdToPictureColumnConverterquoteIdToBalloonColumnConverter转换器获取引语的ID属性,并根据奇偶校验值(即ID是偶数还是奇数)返回气泡对话矩形和角色图片的正确网格列值。根据此示例图片,气泡显示在右侧,角色图片显示在左侧。
XAML
<Grid HorizontalAlignment="Stretch" 
        Visibility="{Binding IsSpeech, Converter={StaticResource 
        booleanToVisibilityConverter}}">
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="100"/>
        <ColumnDefinition Width="250"/>
        <ColumnDefinition Width="100"/>
    </Grid.ColumnDefinitions>
    <StackPanel Grid.Column="{Binding Id, 
    Converter={StaticResource quoteIdToPictureColumnConverter}}" 
    VerticalAlignment="Center">
        <Image Source="{Binding Image}" Height="100" 
        Width="100"></Image>
        <TextBlock Text="{Binding Character}"/>
    </StackPanel>
    <Grid Grid.Column="{Binding Id, Converter={StaticResource 
    quoteIdToBalloonColumnConverter}}" 
    Grid.ColumnSpan="2" Margin="5,15,5,15">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="20"/>
            <ColumnDefinition/>
            <ColumnDefinition Width="20"/>
        </Grid.ColumnDefinitions>
        <Path Grid.Column="0" Visibility="{Binding Id, 
        Converter={StaticResource 
        evenQuoteIdToVisibilityConverter}}" 
        Fill="{Binding Id, Converter={StaticResource 
        quoteIdToBaloonBrushConverter}}" 
        Data="M0,1 L1,0 L1,2 L0,1" Height="20" 
        Width="20" Stretch="Fill"/>
        <Rectangle Grid.Column="1" Fill="{Binding Id, 
        Converter={StaticResource quoteIdToBaloonBrushConverter}}" 
        RadiusX="4" RadiusY="4"/>
        <TextBlock Grid.Column="1" Text="{Binding Speech}" 
        FontSize="26" 
        TextWrapping="Wrap" Margin="4" Foreground="{Binding Id, 
        Converter={StaticResource 
        quoteIdToFontBrushConverter}}"></TextBlock>
        <Path Grid.Column="2" Visibility="{Binding Id, 
        Converter={StaticResource oddQuoteIdToVisibilityConverter}}" 
        Fill="{Binding Id, Converter={StaticResource 
        quoteIdToBaloonBrushConverter}}" 
        Data="M1,1 L0,0 L0,2 L1,1" Height="20" Width="20" Stretch="Fill"/>
    </Grid>
</Grid>

最终考虑

非常感谢您的耐心。希望您喜欢这篇文章,也喜欢我在这里介绍的概念。如果您想分享投诉、问题、想法或建议,请随时在下面的评论区发表评论。

历史

  • 2011-12-29:初始版本。
© . All rights reserved.