microsoft / microsoft-ui-xaml

Windows UI Library: the latest Windows 10 native controls and Fluent styles for your applications
MIT License
6.28k stars 674 forks source link

Proposal: Add a way to do a theoretical measure of a TextBlock #1226

Open verelpode opened 5 years ago

verelpode commented 5 years ago

Please correct me if there is something I don't know: The current version of WinUI seems to have a problem when apps need to measure the width and height of text. People suggest using Windows.UI.Xaml.UIElement.Measure in order to measure text, like this:

TextBlock myTextBlock = ...;
myTextBlock.Text = "The text to be measured.";
myTextBlock.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
Size textSize = myTextBlock.DesiredSize;

AFAIK, the above technique is buggy because it disrupts or interferes with the measure & arrange process/system (depending on when/where you invoke Measure and what value you supply for the availableSize parameter of Measure). For example, this can happen:

  1. Windows.UI.Xaml.UIElement.InvalidateMeasure and/or InvalidateArrange is invoked in the normal manner at the normal time.
  2. InvalidateMeasure sets its internal "IsMeasureValid" field/flag to false (the WPF equivalent is System.Windows.UIElement.IsMeasureValid).
  3. InvalidateMeasure schedules a measure pass (an update). The measure pass is not performed immediately, rather it is scheduled to be performed later-but-soon.
  4. Before Windows.UI.Xaml.UIElement.Measure is invoked in the normal manner in response to the scheduling initiated by InvalidateMeasure, the app decides that it needs to measure text and so it invokes myTextBlock.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
  5. When the app invokes Measure, Measure sets the internal "IsMeasureValid" field/flag to true.
  6. Shortly thereafter, Measure is invoked in the normal manner in response to the scheduling initiated by InvalidateMeasure, but it does nothing because "IsMeasureValid" is already true.
  7. myTextBlock.DesiredSize (UIElement.DesiredSize) now contains an incorrect size because Measure was invoked with size double.PositiveInfinity instead of the real availableSize value calculated during a normal measure pass.
  8. Shortly thereafter, a scheduled arrange pass happens to be executed (coincidentally), and it uses the incorrect value that is stored in myTextBlock.DesiredSize.

In WPF, one way of solving this problem is to stop using Measure and instead use System.Windows.Media.FormattedText to measure text, but FormattedText does not exist in UWP/WinUI. Am I unaware of an alternative or equivalent to FormattedText? Does there exist a recommended way to measure text in UWP/WinUI?

I suggest that Windows.UI.Xaml.Controls.TextBlock be given a new method named "MeasureTheoretically" that operates like Windows.UI.Xaml.UIElement.Measure except that it performs the measurement only "in theory" and does not change any properties or internal fields in TextBlock. Thus it would not change UIElement.DesiredSize nor the internal "IsMeasureValid" field/flag. It could be defined like this:

public Windows.Foundation.Size MeasureTheoretically(Windows.Foundation.Size availableSize);

To make it even better, I suggest adding a "text" parameter as follows:

public Windows.Foundation.Size MeasureTheoretically(Windows.Foundation.Size availableSize, string text = null);

Alternatively, the availableSize parameter could be removed entirely and it would act if availableSize is always double.PositiveInfinity. In this case, it would be useful to have a parameter that overrides the values of Windows.UI.Xaml.FrameworkElement.Width and Height, such as:

public Windows.Foundation.Size MeasureTheoretically(Windows.Foundation.Size elementSize, string text = null);

In some cases, apps don't need to perform a full measurement, rather they just need to get the line height. This means they need to get the actual/effective value of Windows.UI.Xaml.Controls.TextBlock.LineHeight but they cannot read the LineHeight property in order to get this value, because usually LineHeight returns zero. The default value of LineHeight is zero and it means that the line height is determined automatically. Thus I suggest that TextBlock be given a get-only "ActualLineHeight" or "EffectiveLineHeight" property like this:

public double ActualLineHeight { get; }
codendone commented 5 years ago

You are correct that manually calling Measure or Arrange on a TextBlock (or any other element) can cause problems if done at the wrong time. The primary use, of course, is to call these in MeasureOverride/ArrangeOverride of a Panel subclass on that Panel's direct children elements. Any other use should be careful to avoid the issues you mentioned.

One safe use is to call Measure() on a TextBlock which is not in the tree, so its measure/arrange dirty state doesn't matter.

We agree that having an explicit mechanism to measure text would be useful, and we have come close a couple of times to adding a feature for this. There are a couple challenges which come up:

  1. API: How do we keep the API simple yet still support full flexibility so it isn't too limited? Just specifying the text would be nice, but clearly at least the FontFamily and FontSize are also needed. And if we don't support the full TextBlock features, how much does that limit the utility of the new API? If we need the full TextBlock features, are we just duplicating the full TextBlock API, in which case devs might as well just create an unparented TextBlock with all the right properties and call Measure() on it?
  2. Performance: Calling Measure or Arrange on a TextBlock caches measurement information in the TextBlock specific to that TextBlock's text and font properties. Further Measure/Arrange calls on that TextBlock use that cached information to run much more quickly than that first call. If we create a separate API to measure text, then either it won't take advantage of this caching, or we need more complex API changes to ensure the target TextBlock can cache the measurement info.

Your proposal to MeasureTheoretically is an interesting idea to allow measuring size while avoiding the two challenges above (at least without the second text parameter).

It would be great if you could open a Proposal to add a text measurement API. Your MeasureTheoretically idea can be the initial proposal for that, and we can see if the community has other ideas or other scenarios for text measurement.

jevansaks commented 5 years ago

How about we just add the label and edit the title to morph this into a feature proposal?

verelpode commented 5 years ago

How do we keep the API simple yet still support full flexibility so it isn't too limited?

I like this balanced attitude very much, so I've quoted it to highlight it for the benefit of everyone who may happen to read this issue.

Your proposal to MeasureTheoretically is an interesting idea to allow measuring size while avoiding the two challenges above (at least without the second text parameter).

OK, if your opinion is that the text override parameter is troublesome, then let's remove it from the proposal. It's a non-essential parameter.

Re the Size parameter, it could be one of these:

Without being able to see the source code of TextBlock, I'm having difficulty deciding which one of the above is best. Which one do you think is probably the best?

One safe use is to call Measure() on a TextBlock which is not in the tree, so its measure/arrange dirty state doesn't matter.

Although that's a very good tip, it's not always practical. For example, my real scenario faced this week requires that a TextBlock be in the tree because the text needs to be displayed to the user. Thus I cannot safely call Measure because it is in the tree and cannot be removed. To work around this problem, I'm forced to create 2 identical TextBlocks. Identical except that one is in the tree and the other is unparented. The following properties in the in-tree TextBlock must be copied to the unparented TextBlock:

These properties are not only copied once, but rather it requires that the two TextBlocks be synchronized/mirrored. Each time one of those properties is changed in the in-tree TextBlock, it must also be changed in the unparented TextBlock. Impractical. The MeasureTheoretically proposal would eliminate the duplicate TextBlock.

In addition to MeasureTheoretically (not instead of), what do you think of the idea of creating the following static/non-instance method in TextBlock or other suitable class?

public static Size MeasureText(string text, Size maxSize, TextWrapping wrapping, FontFamily font, FontSize fontSize, FontWeight? weight = null, FontStyle? style = null, FontStretch? stretch = null);

When the wrapping is parameter is Wrap, the line breaks are placed according to maxSize.Width. When the text is very long and the lines wrap beyond maxSize.Height, then the method stops measuring (doesn't need to process the remainder of the text) and limits the returned height to maxSize.Height.

As you can see, this non-instance method is simple and easy-to-use, but supports fewer options than MeasureTheoretically, but it supports the most common cases. This method would allow apps to easily measure text in the most common cases, without requiring the creation of a TextBlock or System.Windows.Media.FormattedText instance or any other instance. In cases where uncommon options (such as BaselineOffset) are required, it is reasonable that more work is required, and apps can use MeasureTheoretically in those cases.

The WinUI team could also decide whether or not to support 2 overloads of the non-instance method: One for string text, and one for InlineCollection:

public static Size MeasureText(string text, Size maxSize, ...);
public static Size MeasureText(System.Windows.Documents.InlineCollection inlines, Size maxSize, ...);

I wouldn't expect the non-instance method to cache the resulting Size. It's reasonable to expect the app to cache the Size by itself by storing it in a field. Does any other significant caching/performance problem exist in the non-instance method idea? I'm referring to where you wrote:

TextBlock caches measurement information in the TextBlock specific to that TextBlock's text and font properties. [...] If we create a separate API to measure text, then either it won't take advantage of this caching, or we need more complex API changes to ensure the target TextBlock can cache the measurement info.

I haven't seen the source code but I guess that this issue of cached measurement info might mean that the non-instance method should only support string text and not InlineCollection? Even if the non-instance method only supports string text, it would still be quite convenient in many places. Whenever the app's requirements are more complex, or whenever a TextBlock instance already exists anyway, MeasureTheoretically could be used.

verelpode commented 5 years ago

Multi-Monitor Compatibility

@codendone

One safe use is to call Measure() on a TextBlock which is not in the tree, so its measure/arrange dirty state doesn't matter.

But does a multi-monitor environment break the unparented TextBlock technique? (Admittedly my description following is possibly inaccurate or outright incorrect because I might be mixing up the behavior of WPF versus UWP/WinUI, and because I don't have the considerable time necessary to fully research the multi-monitor issue. So please just tell me if I'm incorrect.)

For example, a comparison with WPF: System.Windows.Media.FormattedText.Width/Height is akin to measuring text in an unparented manner. However, IIRC this broke compatibility with multi-monitor environments (correct?). In order to solve this problem, the FormattedText.PixelsPerDip property was added starting in .NET Framework 4.6.2. This property was also added as a parameter to the constructors. Furthermore, the older constructors that don't have a pixelsPerDip parameter are now marked with ObsoleteAttribute and display a warning in Visual Studio and in the online documentation:

public class FormattedText
{
     [Obsolete("Use the PixelsPerDip override", false)]
     public FormattedText(string textToFormat, CultureInfo culture, FlowDirection flowDirection, 
          Typeface typeface, double emSize, Brush foreground)
     { ... }

     // New constructor with the necessary pixelsPerDip parameter:
     public FormattedText(string textToFormat, CultureInfo culture, FlowDirection flowDirection,
          Typeface typeface, double emSize, Brush foreground,
          double pixelsPerDip)
     { ... }
}

In order to get the correct PixelsPerDip for a given FrameworkElement in the tree, while being compatible with multi-monitor, you can do:

WinXaml.FrameworkElement element = ...;
double ppd = System.Windows.Media.VisualTreeHelper.GetDpi(element).PixelsPerDip;
var ft = new FormattedText(..., pixelsPerDip: ppd);

However, that's WPF. Is this a non-issue in UWP/WinUI even when the computer has multiple monitors? In UWP, the following is true: PixelsPerDip == Windows.Graphics.Display.DisplayInformation.RawPixelsPerViewPixel

But that's a per-instance property. To read it, you can do:

double ppd = Windows.Graphics.Display.DisplayInformation.GetForCurrentView().RawPixelsPerViewPixel;

Thus a bug would occur if you measured an unparented TextBlock and then used this measurement info in a different CoreApplicationView that happens to be displayed on a different monitor with different RawPixelsPerViewPixel. Although my proposed MeasureTheoretically eliminates this problem with unparented TextBlocks, maybe this multi-monitor bug doesn't occur anyway because if you try to access a TextBlock created in a different CoreApplicationView (different thread), it throws an exception, regardless of whether the TextBlock is unparented or not.

BUT WAIT! What about the new Windows.UI.WindowManagement.AppWindow API? It allows apps to create multiple windows in the same thread. Does AppWindow break the unparented TextBlock technique when run in a multi-monitor environment? If the answer is Yes, then again the proposed MeasureTheoretically is advantageous because it works with a TextBlock in the tree, without disrupting Measure/Arrange passes, and with multi-monitor compatibility.

Re my other proposed method; the static/non-instance MeasureText method: Does this method need to be given a double rawPixelsPerViewPixel parameter akin to FormattedText.PixelsPerDip? The parameter could be any one of the following ways of providing direct or indirect access to the correct RawPixelsPerViewPixel value:

verelpode commented 5 years ago

MeasureTheoretically for all!

What do you think of the idea of creating MeasureTheoretically in UIElement (or FrameworkElement) instead of only in TextBlock? Although TextBlocks are the most common scenario/need for this feature, it would also be useful to have the ability to measure other elements.

Currently Measure is implemented approximately (in principle) like this:

public void Measure(Size availableSize)
{
    ...
    this._desiredSize = MeasureOverride(availableSize);
    this.MeasureDirty = false;
    ...
}

protected virtual Size MeasureOverride(Size availableSize)
{ ... }

This means that MeasureTheoretically could be implemented in FrameworkElement similar to this:

// Note virtual to allow subclasses to override if necessary.
public virtual Size MeasureTheoretically(Size availableSize)
{
    // Don't change this._desiredSize nor this.MeasureDirty.
    return MeasureOverride(availableSize);
}

That would instantly work correctly with nearly all subclasses of FrameworkElement, wouldn't it? I guess that only a small number of classes would need to be changed to be compatible with MeasureTheoretically -- that is any classes that implement MeasureOverride in a way that modifies their internal fields in an unsafe manner (unsafe meaning producing a side-effect that disrupts the normal Measure/Arrange passes). Those classes would need to be changed -- they would need to override MeasureTheoretically and provide a safe implementation.

Alternatively, if you prefer, UIElement could define it in an opt-in manner for the backwards-compatibility reasons, like this:

public virtual Size MeasureTheoretically(Size availableSize)
{
    throw new System.NotSupportedException();
}
mdtauk commented 5 years ago

Would this MeasureTheoretically method take account of Margins + Padding, Transparent backgrounds, Null Backgrounds, Focus Rectangles with negative margins, clipped elements, etc

verelpode commented 5 years ago

@mdtauk -- I'd propose that all of those factors be handled in the exact same manner as the existing Measure/DesiredSize. Both Margin and Padding are included in UIElement.DesiredSize, therefore MeasureTheoretically would be the same. Actually in my mind it makes more sense to exclude Margin and include Padding, but it'd be confusing if Measure and MeasureTheoretically behave differently in regards to Margin, therefore I think it's overall better to keep it the same as the preexisting behavior.

As for the other proposed method -- the simple non-instance MeasureText method -- no margin or padding parameters would be supported in that method because they're unnecessary.

jschroedl commented 1 year ago

The proposed MeasureTheoretically for all UIElements would be absolutely wonderful for my usage! Coming from WPF, I was not able to call UpdateLayout and check the DesiredSize for an element b/c UpdateLayout can result in a message loop and queued events could run when it was not appropriate. So, I created code to do my own (usually buggy) MeasureTheoretically for Buttons, etc. So, considering porting to WinUI3, I would LOVE to be able to avoid possible reentrancy issues here and also measure controls for layout (we built UI in code, not XAML). So please create this feature!!!