-
Notifications
You must be signed in to change notification settings - Fork 1.9k
[Android] Correctly scale Button image #19834
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
85898e9
c9cfc73
201a565
777c31d
2629234
cd695f7
497d887
efafcc6
a07258c
dc308ec
64bf6a6
c00258b
632235d
a7834a2
24d1697
c7ace2e
52b7ece
269f0f5
1a679ec
f87b33e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| } | ||
|
|
||
|
|
@@ -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); | ||
| } | ||
|
|
||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| } | ||
| } | ||
| } | ||
|
|
||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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.