Skip to content

Commit

Permalink
[Android] Correctly scale Button image (#19834)
Browse files Browse the repository at this point in the history
* Fixed on Android

* Added snapshots

* Fix on iOS

* Fixed broken test

* Added iOS snapshot

* Changed logic to resize on iOS (now rescale)

* Updated snapshots

* Moved changes in Button iOS from Core to Controls

* Revert Windows related changes

* Revert iOS snapshot

* Revert more changes

* Resize the image to fit the button

* Use the correct measure spec mode

* nice things

* this

* More things

* add a comment

* rename

* Update Issue18242.cs

* Add files via upload

---------

Co-authored-by: Matthew Leibowitz <[email protected]>
  • Loading branch information
jsuarezruiz and mattleibow authored Mar 16, 2024
1 parent 7283fdc commit 1b423ab
Show file tree
Hide file tree
Showing 8 changed files with 177 additions and 31 deletions.
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)
{
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);

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);
}
}
}
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
{
// 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;
}

// 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);
}
}
}

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

0 comments on commit 1b423ab

Please sign in to comment.