dotnet / maui

.NET MAUI is the .NET Multi-platform App UI, a framework for building native device applications spanning mobile, tablet, and desktop.
https://dot.net/maui
MIT License
21.63k stars 1.61k forks source link

iOS: button won't expand vertically to fit long text when LineBreakMode="WordWrap" #22054

Open Silverado-by opened 2 weeks ago

Silverado-by commented 2 weeks ago

Description

Button height behaves differently in Android and iOS when there is long text inside that doesn't fit button properly and LineBreakMode is set to "WordWrap". In Android, it expands vertically to fit all text while in iOS vertical size stays the same while text spreads outside of the button button

<Button
    Text="some very long text that doesn't fit in the button normally and takes four lines of text" 
    WidthRequest="180"
    LineBreakMode="WordWrap"
    HorizontalOptions="Fill" />

Steps to Reproduce

Run application from the linked repository in Android and iOS

Link to public reproduction project repository

https://github.com/Silverado-by/ButtonTest

Version with bug

8.0.7 SR2

Is this a regression from previous behavior?

Yes, this used to work in Xamarin.Forms

Last version that worked well

Unknown/Other

Affected platforms

iOS

Affected platform versions

iOS 17.2

Did you find any workaround?

No, but I'd like to know if there is any

Relevant log output

No response

RoiChen001 commented 2 weeks ago

Can repro this issue at iOS platform on the latest 17.10 Preview 5 (8.0.6&8.0.21).

bzd3y commented 2 weeks ago

@Silverado-by I actually don't think this worked in Xamarin.Forms. Or maybe it did in versions near the end, but was broken before that and I never knew to remove the workaround I had in place. Unless this is somehow a different issue than what I encountered in Xamarin.

I had to create a Renderer in Xamarin to solve, I think, this exact same problem and I have migrated that to a Handler in MAUI. Here is the ConnectHandler() for a ButtonHandler that should fix it. You should also be able to use this in something like ButtonHandler.Mapper.AppendToMapping but I didn't because I was using a subclass of Button anyway, although I should probably just apply it to all buttons.

You might notice the line platformView.TitleLabel.Text = platformView.TitleLabel.Text ?? ""; which seems unnecessary, but if I recall, this code didn't work correctly when text was null and I believe it can run before the button's text is set, with the SizeChanged event taking care of the problem after it gets set. I haven't tested, but this line may no longer be necessary in MAUI. This could also probably be fixed by just doing a null check on TitleLabel.Text in the SizeChanged event.

The same might be true for the last 3 lines setting TitleLabel property in the if. I'm not sure if they are necessary in MAUI.

protected override void ConnectHandler(UIButton platformView)
{
    if (VirtualView is Button button)
    {
        bool resized = false;

        platformView.TitleLabel.Text = platformView.TitleLabel.Text ?? "";

        button.SizeChanged += (s, e) =>
        {
            if (resized == false)
            {
                CGRect unconstrainedBounds = ((NSString)platformView.TitleLabel.Text).GetBoundingRect(new CGSize(double.MaxValue, double.MaxValue), NSStringDrawingOptions.UsesLineFragmentOrigin, new UIStringAttributes { Font = platformView.TitleLabel.Font }, null);
                CGRect constrainedBounds = ((NSString)platformView.TitleLabel.Text).GetBoundingRect(new CGSize(button.Width - button.Padding.HorizontalThickness, double.MaxValue), NSStringDrawingOptions.UsesLineFragmentOrigin, new UIStringAttributes { Font = platformView.TitleLabel.Font }, null);

                double difference = button.Height - unconstrainedBounds.Height;

                button.HeightRequest = Math.Max(button.Height, constrainedBounds.Height + difference);

                resized = true;
            }
        };

        platformView.TitleLabel.LineBreakMode = UILineBreakMode.WordWrap;
        platformView.TitleLabel.Lines = 0;
        platformView.TitleLabel.TextAlignment = UITextAlignment.Center;
    }

    base.ConnectHandler(platformView);
}

EDIT: Actually, I just checked, and it doesn't look like this code actually works in MAUI at all. So I'll need to figure that out. But maybe this code can put people in the right direction. I'll update if I figure out a workaround.

bzd3y commented 1 week ago

After looking into this more and trying to figure out a work around, I think the issue is that in ButtonExtensions.UpdateContentLayout() a private method GetTitleBoundingRect() to calculate the rectangle to hold the title text.

This approach seems wrong on possibly two accounts:

  1. GetTitleBoundingRect() uses NSAttributedString.GetBoundingRect() to calculate the text height. But it passes in the button's height as the height for the size parameter. So when LineBreakMode is something that would wrap and require a larger height it doesn't get to use it.
  2. The UIButton.TitleLabel either should already have been resized to the correct size based on the Text and LineBreakModes and then its Bounds.Height could just be used instead of calculating it all over again or if not, then UIButton.LayoutSubviews() could be called in UpdateContentLayout() to make sure that it is and then its height could be used going forward.

Number 2 seems like the correct approach, along with possible calling LayoutSubviews() no matter what in UpdateContentLayout() just to make sure the subviews are the correct size.

Because a lot of this is done with private/internal APIs, so far it has been pretty difficult to find a workaround.

I have something that somewhat works that I could make due with, but it doesn't take into account a button image or other things like a true workaround would.

The code below should somewhat solve the problem, but is not ideal:

ButtonHandler.Mapper.AppendToMapping(nameof(Button.Text), (h, b) =>
{
    try
    {
        if (b is Button button)
        {
            h.PlatformView.LayoutSubviews();

            double height = h.PlatformView.TitleLabel.Bounds.Height + button.Padding.VerticalThickness;

            if (button.MinimumHeightRequest < height)
            {
                button.MinimumHeightRequest = height;
            }
        }
    }
    catch (Exception ex)
    {

    }
});
Silverado-by commented 1 week ago

I don't think this solution is working for me since h.PlatformView.TitleLabel.Bounds.Height is the same independently of the real height of the label, tested in the sample project above it's 22 for both button labels.

bzd3y commented 1 week ago

@Silverado-by With this exact code? Make sure you are doing the LayoutSubviews() call or the TitleLabel height won't get updated.

I did notice that they changed the involved code some between 8.0.7 and 8.0.21, so if you are on 8.0.21 now then the code above may no longer work, though I can't immediately see why.

I am working on a better workaround that hopefully I will be able to post shortly, but it hasn't been easy due to a lot of the API being private/internal.

This might end up requiring a derived ButtonHandler and/or derived Button, which I have already attempted to do but with no success.

Ultimately the problem seems to be that none of the sizing/layout methods for Buttons take into account the actual size of the text and the layout engine doesn't take into account the actual size of a button and uses its "desired size" which seems to just mean whatever width it has available and then 1 line of text.

I'll try to post something more useful soon.

EDIT: Actually, I noticed why this might not be working. Try changing nameof(Button.Text) to nameof(Button.ContentLayout). I think I might have copied the wrong thing and it should be "ContentLayout".

An issue I have run into that makes this even trickier is that the UIButton.TitleLabel.Text has not actually been set when most of these handlers run (this is why I was setting it manually in my original workaround) even though both the Button and UIButton have their text set, the label itself does not yet, apparently due to the way Apple updates it.

Silverado-by commented 1 week ago

Thank you! nameof(Button.ContentLayout) did the trick for me!

bzd3y commented 1 day ago

@Silverado-by No problem. I think I have a better/more complete workaround, although I haven't tested it fully. But so you don't have to wait, I'll post it here. Override GetDesiredSize() in a new or existing derived ButtonHandler (remember to register it) with the following:

    public override Size GetDesiredSize(double widthConstraint, double heightConstraint)
    {
        Size desiredSize = base.GetDesiredSize(widthConstraint, heightConstraint);

        if (VirtualView is Button button && button.HeightRequest == -1 && (button.LineBreakMode == LineBreakMode.WordWrap || button.LineBreakMode == LineBreakMode.CharacterWrap))
        {
            double width = widthConstraint - button.Padding.HorizontalThickness;
            CGSize size = new(width, heightConstraint);
            NSAttributedString title = PlatformView.CurrentAttributedTitle ?? new NSAttributedString(PlatformView.CurrentTitle, new UIStringAttributes()
            {
                Font = PlatformView.TitleLabel.Font
            });
            CGRect bounds = title.GetBoundingRect(size, NSStringDrawingOptions.UsesLineFragmentOrigin | NSStringDrawingOptions.UsesFontLeading, null);
            double height = bounds.Height;

            desiredSize.Height = height + button.Padding.VerticalThickness;
        }

        return desiredSize;
    }

This is as close as I could get. It works fairly well with Text and LineBreakMode changes, although I'm not sure if both WordWrap and CharacterWrap work correctly since it doesn't seem like there is a way to specify which one NSAttributedString.GetBoundingRect() uses. But they both seem to work, so I have no idea how that is possible. The height for CharacterWrap seems to be too high. But in that case the text height will be lower than WordWrap so the button still looks fine, more or less.

~I have a feeling it will not work with CharacterSpacing changes, but I still have some testing to do.~ Actually it does. I'm not sure how that works unless the AttributedString just knows about the character spacing.

I haven't tested font changes either, but I think that should work.

I haven't figured out a way to do it with handler mapping because there doesn't seem to be any way to set/override the Button's desired size outside of the handler.

It also doesn't work with an button image, but as far as I can tell images and text in a button doesn't really work anyway.

Frankly, the entire ContentLayout concept of the buttons seems broken, at least as far as being customizable. I tried to get an image to work, but I gave up since I don't really have a need for that right now anyway.