Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,14 @@
CharacterSpacing="20"
TextColor="HotPink"
Text="Button"/>
<Label
Text="Actual Image"
Style="{StaticResource Headline}"/>
<Grid>
<Image
Background="Black" Source="settings.png"
HorizontalOptions="Center" VerticalOptions="Center" />
</Grid>
<Label
Text="Image Source"
Style="{StaticResource Headline}"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,35 +36,24 @@ public static void UpdateContentLayout(this MaterialButton materialButton, Butto
{
var contentLayout = button.ContentLayout;

// IconPadding calls materialButton.CompoundDrawablePadding
// IconPadding calls materialButton.CompoundDrawablePadding
// Which is why we don't have to worry about calling setCompoundDrawablePadding
// ourselves for our custom implemented IconGravityBottom
materialButton.IconPadding = (int)context.ToPixels(contentLayout.Spacing);

// For IconGravityTextEnd and IconGravityTextStart, setting the Icon twice
// is needed to work around the Android behavior that caused
// https://github.com/dotnet/maui/issues/11755
switch (contentLayout.Position)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of toggling the icon here, we are doing that as part of the measure. This makes the updates happen all together instead of here, and then it is better for maintenance.

{
case ButtonContentLayout.ImagePosition.Top:
materialButton.Icon = null;
materialButton.IconGravity = MaterialButton.IconGravityTop;
materialButton.Icon = icon;
break;
case ButtonContentLayout.ImagePosition.Bottom:
materialButton.Icon = null;
TextViewCompat.SetCompoundDrawablesRelative(materialButton, null, null, null, icon);
materialButton.IconGravity = MauiMaterialButton.IconGravityBottom;
break;
case ButtonContentLayout.ImagePosition.Left:
materialButton.Icon = null;
materialButton.IconGravity = MaterialButton.IconGravityTextStart;
materialButton.Icon = icon;
break;
case ButtonContentLayout.ImagePosition.Right:
materialButton.Icon = null;
materialButton.IconGravity = MaterialButton.IconGravityTextEnd;
materialButton.Icon = icon;
break;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Controls/tests/UITests/Tests/Issues/Issue18242.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public Issue18242(TestDevice device) : base(device)
[Test]
public void Issue18242Test()
{
this.IgnoreIfPlatforms(new TestDevice[] { TestDevice.Android, TestDevice.Mac, TestDevice.iOS }, "Only Windows for now");
this.IgnoreIfPlatforms(new TestDevice[] { TestDevice.Mac, TestDevice.iOS }, "iOS will be fixed in https://github.com/dotnet/maui/pull/20953");

App.WaitForElement("WaitForStubControl");

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 6 additions & 2 deletions src/Core/src/Handlers/Button/ButtonHandler.Android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ public static Task MapImageSourceAsync(IButtonHandler handler, IImage image)

public override void PlatformArrange(Rect frame)
{
// The TextView might need an additional measurement pass at the final size
this.PrepareForTextViewArrange(frame);
Comment on lines +126 to 127
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed because TextView controls may be measured with an "AtMost" which means the measure is more "give me the smallest size". Because the text in a TextView is positioned using an absolute coordinate, it needs to be re-measured with the final sizes to get the new coordinates.


base.PlatformArrange(frame);
}

Expand Down Expand Up @@ -156,7 +158,7 @@ void OnNativeViewFocusChange(object? sender, AView.FocusChangeEventArgs e)

void OnPlatformViewLayoutChange(object? sender, AView.LayoutChangeEventArgs e)
{
if (sender is MaterialButton platformView && VirtualView != null)
if (sender is MaterialButton platformView && VirtualView is not null)
platformView.UpdateBackground(VirtualView);
}

Expand Down Expand Up @@ -185,7 +187,9 @@ public override void SetImageSource(Drawable? platformImage)
if (Handler?.PlatformView is not MaterialButton button)
return;

button.Icon = platformImage;
button.Icon = platformImage is null
? null
: new MauiMaterialButton.MauiResizableDrawable(platformImage);
Comment on lines +190 to +192
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we set the icon, we wrap it in a special "resizable drawable" that allows us to give the icon a specific size that is used by the measure and layout passes.

}
}
}
Expand Down
10 changes: 9 additions & 1 deletion src/Core/src/Handlers/ViewHandlerExtensions.Android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ internal static void PlatformArrangeHandler(this IViewHandler viewHandler, Rect
viewHandler.Invoke(nameof(IView.Frame), frame);
}

/// <summary>
/// The measure pass might have an Unspecified/AtMost measure specs,
/// and this means the text is probably on the edge of the view.
/// This is because the view is trying to take up the least amount of
/// space possible.
/// In order to finally place the text in the correct position,
/// we need to measure it again with more exact/final sizes.
/// </summary>
internal static void PrepareForTextViewArrange(this IViewHandler handler, Rect frame)
{
if (frame.Width < 0 || frame.Height < 0)
Expand Down Expand Up @@ -162,7 +170,7 @@ internal static void PrepareForTextViewArrange(this IViewHandler handler, Rect f
}
}

internal static bool NeedsExactMeasure(this IView virtualView)
static bool NeedsExactMeasure(this IView virtualView)
{
if (virtualView.VerticalLayoutAlignment != Primitives.LayoutAlignment.Fill
&& virtualView.HorizontalLayoutAlignment != Primitives.LayoutAlignment.Fill)
Expand Down
164 changes: 149 additions & 15 deletions src/Core/src/Platform/Android/MauiMaterialButton.cs
Original file line number Diff line number Diff line change
@@ -1,35 +1,169 @@
using Android.Content;
using System;
using Android.Content;
using Android.Graphics.Drawables;
using Android.Views;
using AndroidX.Core.Widget;
using Google.Android.Material.Button;

namespace Microsoft.Maui.Platform
{
public class MauiMaterialButton : MaterialButton
{
// Currently Material doesn't have any bottom gravity options
// so we just move the layout to the bottom using
// SetCompoundDrawablesRelative during Layout
internal const int IconGravityBottom = 9999;
public MauiMaterialButton(Context context) : base(context)
// The default MaterialButton currently does not have a concept of bottom
// gravity which we need for .NET MAUI.
// In order to get this feature, we have added a custom gravity option
// that serves as a flag to indicate that the icon should be placed at
// the bottom.
// The real gravity value is IconGravityTop in order to perform all the
// normal layout calculations. We then set ForceBottomIconGravity for our
// custom layout pass where we simply swap the icon from the top to the
// bottom using SetCompoundDrawablesRelative.
internal const int IconGravityBottom = 0x1000;

public MauiMaterialButton(Context context)
: base(context)
{
}

protected override void OnLayout(bool changed, int left, int top, int right, int bottom)
public override int IconGravity
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we set an invalid/new value here, then the measure and layout passes will not work properly. Luckily, we can pretend that bottom is top, and then swap to the bottom at the last minute after the layout has completed.

{
// These are hacks that seem to force the button to measure correctly
// when using top or bottom positioning.
if (IconGravity == IconGravityBottom)
get => base.IconGravity;
set
{
var drawable = TextViewCompat.GetCompoundDrawablesRelative(this)[3];
drawable?.SetBounds(0, 0, drawable.IntrinsicWidth, drawable.IntrinsicHeight);
// Intercept the gravity value and set the flag if it's bottom.
ForceBottomIconGravity = value == IconGravityBottom;
base.IconGravity = ForceBottomIconGravity ? IconGravityTop : value;
}
else if (IconGravity == MaterialButton.IconGravityTop)
}

internal bool ForceBottomIconGravity { get; private set; }

protected override void OnMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
if (Icon is MauiResizableDrawable currentIcon)
{
var drawable = TextViewCompat.GetCompoundDrawablesRelative(this)[1];
drawable?.SetBounds(0, 0, drawable.IntrinsicWidth, drawable.IntrinsicHeight);
// if there is BOTH an icon AND text, but the text layout has NOT been measured yet,
// we need to measure JUST the text first to get the remaining space for the icon
if (Layout is null && TextFormatted?.Length() > 0)
{
// remove the icon and measure JUST the text
Icon = null;
base.OnMeasure(widthMeasureSpec, heightMeasureSpec);

// restore the icon
Icon = currentIcon;
}
Comment on lines +45 to +55
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Due to the way TextView measures, it appears to prioritize the icon and will clip/squash the text. However, we want the text to be the priority. In order for this to happen, we have to measure the initial pass without an icon and this sets us up to re-measure with a smaller/fitting icon.


// determine the total client area available for BOTH the icon AND text to fit
var availableWidth = MeasureSpec.GetMode(widthMeasureSpec) == MeasureSpecMode.Unspecified
? int.MaxValue
: MeasureSpec.GetSize(widthMeasureSpec);
var availableHeight = MeasureSpec.GetMode(heightMeasureSpec) == MeasureSpecMode.Unspecified
? int.MaxValue
: MeasureSpec.GetSize(heightMeasureSpec);

// calculate the icon size based on the remaining space
CalculateIconSize(currentIcon, availableWidth, availableHeight);
}

// re-measure with both text and icon
base.OnMeasure(widthMeasureSpec, heightMeasureSpec);
}

protected override void OnLayout(bool changed, int left, int top, int right, int bottom)
{
base.OnLayout(changed, left, top, right, bottom);

// After the layout pass, we swap the icon from the top to the bottom.
if (ForceBottomIconGravity)
{
var icons = TextViewCompat.GetCompoundDrawablesRelative(this);
if (icons[1] is { } icon)
{
TextViewCompat.SetCompoundDrawablesRelative(this, null, null, null, icon);
icon.SetBounds(0, 0, icon.IntrinsicWidth, icon.IntrinsicHeight);
}
}
Comment on lines +78 to +86
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we flip at the end.

}

void CalculateIconSize(MauiResizableDrawable resizable, int availableWidth, int availableHeight)
{
// bail if the text layout is not available yet, this is most likely a bug
if (Layout is null)
{
return;
}

var actual = resizable.Drawable;

var remainingWidth = availableWidth - PaddingLeft - PaddingRight;
var remainingHeight = availableHeight - PaddingTop - PaddingBottom;

if (IsIconGravityHorizontal)
{
remainingWidth -= IconPadding + GetTextLayoutWidth();
}
else
{
remainingHeight -= IconPadding + GetTextLayoutHeight();
}

var iconWidth = Math.Min(remainingWidth, actual.IntrinsicWidth);
var iconHeight = Math.Min(remainingHeight, actual.IntrinsicHeight);

var ratio = Math.Min(
(double)iconWidth / actual.IntrinsicWidth,
(double)iconHeight / actual.IntrinsicHeight);

resizable.SetPreferredSize(
Math.Max(0, (int)(actual.IntrinsicWidth * ratio)),
Math.Max(0, (int)(actual.IntrinsicHeight * ratio)));

// trigger a layout re-calculation
Icon = null;
Icon = resizable;
}

bool IsIconGravityHorizontal =>
IconGravity is IconGravityTextStart or IconGravityTextEnd or IconGravityStart or IconGravityEnd;

int GetTextLayoutWidth()
{
float maxWidth = 0;
int lineCount = LineCount;
for (int line = 0; line < lineCount; line++)
{
maxWidth = Math.Max(maxWidth, Layout!.GetLineWidth(line));
}
return (int)Math.Ceiling(maxWidth);
}

int GetTextLayoutHeight()
{
var layoutHeight = Layout!.Height;

return layoutHeight;
}

internal class MauiResizableDrawable : LayerDrawable
{
public MauiResizableDrawable(Drawable drawable)
: base([drawable])
{
PaddingMode = (int)LayerDrawablePaddingMode.Stack;
}

public Drawable Drawable => GetDrawable(0)!;

public void SetPreferredSize(int width, int height)
{
if (OperatingSystem.IsAndroidVersionAtLeast(23))
{
SetLayerSize(0, width, height);
}

// TODO: find something that works for older versions
}
}
}
}
3 changes: 3 additions & 0 deletions src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ override Microsoft.Maui.Layouts.FlexBasis.Equals(object? obj) -> bool
override Microsoft.Maui.Layouts.FlexBasis.GetHashCode() -> int
override Microsoft.Maui.MauiAppCompatActivity.DispatchTouchEvent(Android.Views.MotionEvent? e) -> bool
override Microsoft.Maui.Platform.ContentViewGroup.GetClipPath(int width, int height) -> Android.Graphics.Path?
override Microsoft.Maui.Platform.MauiMaterialButton.IconGravity.get -> int
override Microsoft.Maui.Platform.MauiMaterialButton.IconGravity.set -> void
override Microsoft.Maui.Platform.MauiScrollView.OnMeasure(int widthMeasureSpec, int heightMeasureSpec) -> void
override Microsoft.Maui.Platform.MauiMaterialButton.OnMeasure(int widthMeasureSpec, int heightMeasureSpec) -> void
override Microsoft.Maui.Platform.NavigationViewFragment.OnDestroy() -> void
override Microsoft.Maui.PlatformContentViewGroup.JniPeerMembers.get -> Java.Interop.JniPeerMembers!
override Microsoft.Maui.PlatformContentViewGroup.ThresholdClass.get -> nint
Expand Down