tmytのらくがき

個人の日記レベルです

InlineUIContainerで追加したImageがOverflowしたときに非表示にする

UWPのRichTextBlockとInlineUIContainer周りでなんだか微妙な気持ちになりました。せっかくなのでエントリしておきます。

TL;DR

  • RichTextBlockに追加したUIElementはOverflowしても非表示にならない
  • InlineUIContainerからGetCharacterRectで矩形を取得して表示判定をする
  • SizeChangedとかで表示非表示コードをいい感じに実行する

いい感じに動いてほしいコード

UWPのRichTextBlockはInlineUIContainerクラスを経由すると任意のUIElementを子要素として持つことができます。 たとえば、RichTextBlockの中に画像をインライン表示したい。とかがよくある要件かと思います。

これを簡単に実現するとこんな風なXAMLになります。

<RichTextBlock x:Name="Text" TextTrimming="CharacterEllipsis" TextWrapping="NoWrap" IsTextSelectionEnabled="False">
    <Paragraph>
        <Run Text="aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa"></Run>
        <Run Text="aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa"></Run>
        <InlineUIContainer>
            <Border Background="Blue">
                <Image Source="Assets/Square150x150Logo.png"
                       Height="{Binding ElementName=Text, Path=FontSize}"
                       Stretch="Uniform" MinWidth="5"/>
            </Border>
        </InlineUIContainer>
    </Paragraph>
</RichTextBlock>

なんかうじゃうじゃ書いてますが、実行するとこういう画面が表示されます。

f:id:tmyt:20180702013821p:plain

期待する結果は表示しきれなくなって文字列が"..."で省略された時にこの画像部分が非表示になってほしいのですが、 何もしないとこうなります。

f:id:tmyt:20180702013850p:plain

釈然としませんが、SizeChangedあたりであふれたかどうか判定をしたうえで、Opacity = 0にするようなコードを書くと期待した結果になります。

うまく動かすコード

このコードをSizeChangedあたりで実行すると、なんかいい感じになります。

foreach (var inline in ((Paragraph)Text.Blocks[0]).Inlines)
{
    if(!(inline is InlineUIContainer container)) continue;
    var uiElement = (FrameworkElement)container.Child;
    var elementStart = container.ElementStart;
    var elementEnd = container.ElementEnd;
    var rect1 = elementStart.GetCharacterRect(elementStart.LogicalDirection);
    var rect2 = elementEnd.GetCharacterRect(elementEnd.LogicalDirection);
    uiElement.Opacity = rect1.Left == rect2.Left ? 0 : 1;
}

ただし、このコードはいくつかの決め打ち要素が含まれています。

  • TextというRichTextBlockがある
  • RichTextBlockのBlocksは1個だけ、しかもそれはParagraph
  • Paragraphの中にネストしたParagraphは存在しない

RichTextBlockが決め打ちなのは各自使いやすくしていただくとして、 Blocksの中身が2個以上だったり、Paragraphじゃない場合があったり、 Windows Runtime環境下では、Blockの派生クラスとして実装されているのはParagraphのみでした。 Paragraph直下以外でInlineUIContainerを含む場合の対応が必要な場合は 各自カスタムして使ってください。

これを実装して、実行するとこんな結果になります。

f:id:tmyt:20180702013934p:plain

UIElementが非表示になっていい感じの結果です。見えないだけで水色っぽいところに配置されています。

ちょっとだけ解説

TextPointer.GetCharacterRectは隣接するテキスト境界のバウンディングボックスを返すような関数らしいです*1。 これを呼び出したとき、コード中のrect1.Leftrect2.Leftは内包するUIElementがあふれていない場合異なる値を返します*2

しかし、あふれている場合に呼び出すとrect1.Leftrect2.Leftは同じ値になります。以降どの文字を調べても同じ値が出てきます。 どうやらこの値は、RichTextBlock.ActualWidth - "...の幅"くらいの値になってるようです*3

という挙動から、2つのRectのLeftプロパティが同値の場合はOpacity = 0とすることで、UIElementを非表示にしています。 ここで、Visibility = Hiddenにすると、2つのLeftプロパティが同値になってしまい再度表示する場合の判定ができなくなります。 また、UIElementのActualWidthが0になると同様に判定に失敗します。今回はMinWidth = 5とし、最低5px確保することでActualWidthが0になることを回避しました。

気が向いたらBehaviorにするかもしれません。

*1:https://docs.microsoft.com/en-us/uwp/api/windows.ui.xaml.documents.textpointer.getcharacterrect#Windows_UI_Xaml_Documents_TextPointer_GetCharacterRect_Windows_UI_Xaml_Documents_LogicalDirection_

*2:だいたい rect1.Left + UIElement.ActualWidth == rect2.Left になります。厳密にはちょっと違う

*3:内部的には"..."の手前に全部幅0で表示してますよ。という感じなのかもしれない