Skip to content
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

Android background_color Transparency Fixes #3118

Open
wants to merge 15 commits into
base: main
Choose a base branch
from

Conversation

proneon267
Copy link
Contributor

@proneon267 proneon267 commented Jan 23, 2025

Originally identified in #2484, this PR separates the changes required to fix the background_color fixes on Android.

Originally posted in: #2484 (comment) :

As I had noted here:

# Most of the Android Widget have different effects applied them which provide
# the native look and feel of Android. These widgets' background consists of
# Drawables like ColorDrawable, InsetDrawable and other animation Effect Drawables
# like RippleDrawable. Often when such Effect Drawables are used, they are stacked
# along with other Drawables in a LayerDrawable.
#
# LayerDrawable once created cannot be modified and attempting to modify it or
# creating a new LayerDrawable using the elements of the original LayerDrawable
# stack, will destroy the native look and feel of the widgets. The original
# LayerDrawable cannot also be properly cloned. Using `getConstantState()` on the
# Drawable will produce an erroneous version of the original Drawable.
#
# These widgets are also affected by the background color of the parent inside which
# they are present. Directly, setting background color also destroys the native look
# and feel of these widgets. Moreover, these effects also serve the purpose of
# providing visual aid for the action of these widgets.
:

What I meant by that is that in some widgets, for example: toga.Selection, when their background_color is set via set_background_simple then the native effects are lost.
So, if I have:

toga.Box(
            style=Pack(flex=1),
            children=[
                toga.Selection(
                    items=["Alice", "Bob", "Charlie"],
                )
            ],
        )

Then the animation effect on interaction would look like:

But now, if I set a background color on the parent like:

toga.Box(
            style=Pack(flex=1, background_color="#87CEFA"),
            children=[
                toga.Selection(
                    items=["Alice", "Bob", "Charlie"],
                )
            ],
        )

Then the animation effect on interaction would look like:

As we can see, the dropdown arrow is not visible, and the ripple effect is also not visible. Further, the widget doesn't set any background color and hence even setting the parent's background color also destroys the native effects. The same problem exists in other widgets also(like toga.Switch, etc.), where the ripple effect will not be visible:

As I have previously noted in the comment, tampering with the background(LayerDrawable) destroys it and with it the animation effects are also destroyed. Hence, I tried to solve this with ContainedWidget class which doesn't require the existing widgets to be changed.

There is also, toga.DetailedList:

        toga.Box(
            style=Pack(flex=1),
            children=[
                toga.DetailedList(
                    data=[
                        {
                            "icon": toga.Icon("icons/arthur"),
                            "title": "Arthur Dent",
                            "subtitle": "Where's the tea?",
                        },
                        {
                            "icon": toga.Icon("icons/ford"),
                            "title": "Ford Prefect",
                            "subtitle": "Do you know where my towel is?",
                        },
                        {
                            "icon": toga.Icon("icons/tricia"),
                            "title": "Tricia McMillan",
                            "subtitle": "What planet are you from?",
                        },
                    ],
                ),
            ],
        )


But now, if I set a background color on the parent like:

        toga.Box(
            style=Pack(flex=1, background_color="#87CEFA"),
            children=[
                toga.DetailedList(...)
            ],
        )


Similar, problems also exist on other widgets also(like toga.Canvas). Hence, for these widgets also, I have usedContainedWidget class to solve these issues.

PR Checklist:

  • All new features have been tested
  • All new features have been documented
  • I have read the CONTRIBUTING.md file
  • I will abide by the code of conduct

@proneon267
Copy link
Contributor Author

proneon267 commented Jan 23, 2025

Originally posted by @mhsmith in #2484 (comment)


As we can see, the dropdown arrow is not visible, and the ripple effect is also not visible. Further, the widget doesn't set any background color and hence even setting the parent's background color also destroys the native effects.

I can confirm that this also happens Toga 0.4.5, so it wasn't introduced by any of the other changes in this PR [#2484]. if we can understand exactly why the problem is happening, then we'll have a better idea of whether this is the simplest solution.

Widgets shouldn't even be aware of their parent's background. But in this case, the Box isn't actually the Selection's parent at the native level: both of them are direct children of the RelativeLayout, and the Box appears behind the Selection because of their order of creation. So maybe certain background features are being drawn directly on the native parent (the RelativeLayout), and the intervening Box is covering it up? Experimenting with semi-transparent backgrounds may answer this.

It may also have something to do with elevation.

@proneon267
Copy link
Contributor Author

proneon267 commented Jan 23, 2025

Readthedocs test failure is unrelated, and is caused due to https://osmfoundation.org/ being down.

@proneon267
Copy link
Contributor Author

proneon267 commented Jan 23, 2025

I experimented with semi-transparent backgrounds, to confirm if the arrow and other effects were being drawn on the native parent instead, and @mhsmith was correct:

 toga.Box(
            style=Pack(flex=1, background_color=rgba(135, 206, 250, 0.5)),
            children=[
                toga.Selection(
                    items=["Alice", "Bob", "Charlie"],
                )
            ],
        )

Screenshot 2025-01-23 001225

The background features are being drawn directly on the native parent(RelativeLayout), as the Box isn't actually the Selection's parent at the native level: both of them are direct children of the RelativeLayout.

To fix this, I have tried the following, but none of them worked:

Using setElevation():

Note: setElevation() takes values in pixels: http://developer.android.com/reference/android/support/v4/view/ViewCompat.html#setElevation(android.view.View,%20float)

  • Only setting the elevation of the toga.Selection widget:

selection_widget._impl.native.setElevation(20 * context.getResources().getDisplayMetrics().density)
  • Setting the elevation of both the toga.Selection and the toga.Box widget:

selection_widget._impl.native.setElevation(20 * context.getResources().getDisplayMetrics().density)
box_widget._impl.native.setElevation(1 * context.getResources().getDisplayMetrics().density)
  • Setting the elevation of the toga.Selection to positive and the toga.Box widget to negative:

selection_widget._impl.native.setElevation(20 * context.getResources().getDisplayMetrics().density)
box_widget._impl.native.setElevation(-10 * context.getResources().getDisplayMetrics().density)

Using bringChildToFront():

parent = selection_widget._impl.native.getParent()
parent.bringChildToFront(selection_widget._impl.native)

setTranslationZ():

selection_widget._impl.native.setTranslationZ(10.0)

Removing and re-adding to the native parent(RelativeLayout):

parent = selection_widget.impl.native.getParent()
parent.removeView(selection_widget.impl.native)
parent.addView(selection_widget.impl.native)

I had also read somewhere that certain features like shadows are only drawn directly on the native parent. As I have also mentioned previously:

# Most of the Android Widget have different effects applied them which provide
# the native look and feel of Android. These widgets' background consists of
# Drawables like ColorDrawable, InsetDrawable and other animation Effect Drawables
# like RippleDrawable. Often when such Effect Drawables are used, they are stacked
# along with other Drawables in a LayerDrawable.
#
# LayerDrawable once created cannot be modified and attempting to modify it or
# creating a new LayerDrawable using the elements of the original LayerDrawable
# stack, will destroy the native look and feel of the widgets. The original
# LayerDrawable cannot also be properly cloned. Using `getConstantState()` on the
# Drawable will produce an erroneous version of the original Drawable.
#
# These widgets are also affected by the background color of the parent inside which
# they are present. Directly, setting background color also destroys the native look
# and feel of these widgets. Moreover, these effects also serve the purpose of
# providing visual aid for the action of these widgets.
:
So, it seems like wrapping the native widget inside another view allows us to preserve the native look and feel, while setting the background color of the widgets.

@freakboy3742
Copy link
Member

I'll need to defer to @mhsmith for suggestions here - my understanding of the inner workings of Android rendering are hazy at best.

@mhsmith
Copy link
Member

mhsmith commented Jan 30, 2025

Thanks, I'll look at this today or tomorrow.

@proneon267
Copy link
Contributor Author

Thank you :)

@mhsmith

This comment was marked as resolved.

@mhsmith

This comment was marked as resolved.

@mhsmith
Copy link
Member

mhsmith commented Feb 3, 2025

The ripple effects are supposed to be circular, but they're being clipped by their native parent:

Screenshot_1738614838

It may be possible to fix this with the "clip" properties (https://stackoverflow.com/questions/30626019).

Comment on lines -30 to -32
@property
def background_color(self):
xfail("Can't change the background color of Selection on this backend")
Copy link
Member

Choose a reason for hiding this comment

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

This PR enables background colors for quite a few other widgets which didn't support them before. Do their tests also need to be updated?

Copy link
Contributor Author

@proneon267 proneon267 Feb 4, 2025

Choose a reason for hiding this comment

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

Yes, the tests should be enabled. However, since background_color is not currently correct on all platforms, so we cannot enable the background color tests on this PR, as the tests would fail on the other platforms(as the fix for those platforms is on separate PRs).

Since, this would lead to a deadlock situation. Hence, I had created #3015 to keep track of the widgets on which all background color tests are not enabled. Once, these background_color fixing PRs are completed, I will enable all background color tests on all platforms.

@proneon267

This comment was marked as resolved.

@proneon267
Copy link
Contributor Author

proneon267 commented Feb 4, 2025

The only remaining currently is the clipping of the ripple effect.

I tried to use the following(on the native_toplevel as well as on other parents like container), but they didn't fix the clipping:

self.native_toplevel.setClipChildren(False)
self.native_toplevel.setClipToPadding(False)
self.native_toplevel.setClipToOutline(False)

However, on further testing, it turns out the problem is due to the incorrect LayoutParams for self.native_toplevel, hence the ripple effect of self.native gets clipped by self.native_toplevel.


Currently, we have:

def set_content_bounds(self, widget, x, y, width, height):
lp = RelativeLayout.LayoutParams(width, height)
lp.topMargin = y
lp.leftMargin = x
widget.native_toplevel.setLayoutParams(lp)

class ContainedWidget(Widget):
def __init__(self, interface):
super().__init__(interface)
self.native_toplevel = RelativeLayout(self._native_activity)
self.native_toplevel.addView(self.native)
self.native.setLayoutParams(
RelativeLayout.LayoutParams(
RelativeLayout.LayoutParams.MATCH_PARENT,
RelativeLayout.LayoutParams.MATCH_PARENT,
)
)

Which produces the clipped ripple effect:

Example source:(The `Selection` widget's background is intentionally kept white)
"""
My first application
"""

import toga
from toga.style import Pack
from toga.style.pack import COLUMN


class HelloWorld(toga.App):
  def reset_to_native_default_background_color(self, widget, **kwargs):
      for widget in self.widgets:
          del widget.style.background_color
      self.content.refresh()

  def startup(self):
      """Construct and show the Toga application.

      Usually, you would add your application to a main content box.
      We then create a main window (with a name matching the app), and
      show the main window.
      """
      self.content = toga.Box(
          style=Pack(
              background_color="#87CEFA",
              direction=COLUMN,
              flex=1,
          ),
          children=[
              toga.Box(style=Pack(flex=1.5)),
              toga.Button(
                  text="Reset to native default background color",
                  on_press=self.reset_to_native_default_background_color,
              ),
              toga.Box(style=Pack(flex=1.5)),
              toga.ImageView(
                  toga.Image(self.paths.app / "resources/imageview.png"),
              ),
              toga.Box(style=Pack(flex=1.5)),
              toga.Label(text="Label"),
              toga.Box(style=Pack(flex=1.5)),
              toga.ProgressBar(max=100, value=50),
              toga.Box(style=Pack(flex=1.5)),
              toga.Selection(
                  items=["Alice", "Bob", "Charlie"],
                  style=Pack(background_color="#ffffff"),
              ),
              toga.Box(style=Pack(flex=1.5)),
              toga.Slider(min=-5, max=10, value=7),
              toga.Box(style=Pack(flex=1.5)),
              toga.NumberInput(value=8908),
              toga.Box(style=Pack(flex=1.5)),
              toga.MultilineTextInput(
                  value="Some text.\nIt can be multiple lines of text."
              ),
              toga.Box(style=Pack(flex=1.5)),
              toga.TextInput(value="Jane Developer"),
              toga.Box(style=Pack(flex=1.5)),
              toga.PasswordInput(value="Jane"),
              toga.Box(style=Pack(flex=1.5)),
              toga.Table(
                  headings=["Name", "Age"],
                  data=[
                      ("Arthur Dent", 42),
                      ("Ford Prefect", 37),
                      ("Tricia McMillan", 38),
                  ],
              ),
              toga.Box(style=Pack(flex=1.5)),
              toga.Switch(text="Switch"),
              toga.Box(style=Pack(flex=1.5)),
              toga.DetailedList(
                  data=[
                      {
                          "icon": toga.Icon("icons/arthur"),
                          "title": "Arthur Dent",
                          "subtitle": "Where's the tea?",
                      },
                      {
                          "icon": toga.Icon("icons/ford"),
                          "title": "Ford Prefect",
                          "subtitle": "Do you know where my towel is?",
                      },
                      {
                          "icon": toga.Icon("icons/tricia"),
                          "title": "Tricia McMillan",
                          "subtitle": "What planet are you from?",
                      },
                  ],
              ),
              toga.Box(style=Pack(flex=1.5)),
              toga.DateInput(),
              toga.Box(style=Pack(flex=1.5)),
              toga.TimeInput(),
              toga.Box(style=Pack(flex=1.5)),
          ],
      )
      self.main_window = toga.MainWindow(title=self.formal_name)
      self.main_window.content = toga.ScrollContainer(content=self.content)
      self.main_window.show()


def main():
  return HelloWorld()

However, logically the correct code should be:

diff --git a/android/src/toga_android/container.py b/android/src/toga_android/container.py
index 714e495ac..d46cabfe3 100644
--- a/android/src/toga_android/container.py
+++ b/android/src/toga_android/container.py
@@ -60,4 +60,4 @@ class Container(Scalable):
         lp = RelativeLayout.LayoutParams(width, height)
         lp.topMargin = y
         lp.leftMargin = x
-        widget.native_toplevel.setLayoutParams(lp)
+        widget.native.setLayoutParams(lp)
diff --git a/android/src/toga_android/widgets/base.py b/android/src/toga_android/widgets/base.py
index 2f4774fca..7cd2eb40c 100644
--- a/android/src/toga_android/widgets/base.py
+++ b/android/src/toga_android/widgets/base.py
@@ -222,10 +222,10 @@ class ContainedWidget(Widget):
         self.native_toplevel = RelativeLayout(self._native_activity)
         self.native_toplevel.addView(self.native)
 
-        self.native.setLayoutParams(
+        self.native_toplevel.setLayoutParams(
             RelativeLayout.LayoutParams(
-                RelativeLayout.LayoutParams.MATCH_PARENT,
-                RelativeLayout.LayoutParams.MATCH_PARENT,
+                RelativeLayout.LayoutParams.WRAP_CONTENT,
+                RelativeLayout.LayoutParams.WRAP_CONTENT,
             )
         )
         # Immediately re-apply styles. Some widgets may defer style application until

But this produces the wrong result:

The native_toplevel should only enclose upto the height of self.native when native_toplevel has WRAP_CONTENT as LayoutParams, but it exceeds beyond the height of self.native. Moreover, the bottom height is also not correct, leading to clipping at the bottom part of the ripple effect.

@mhsmith
Copy link
Member

mhsmith commented Feb 19, 2025

I'm not sure why you think this is related to the LayoutParams. Regardless of whether the parent is sized to the child or vice versa, I expect that if the resulting bounds are the same, then the clipping behavior would be the same too.

I tried to use the following(on the native_toplevel as well as on other parents like container), but they didn't fix the clipping:

self.native_toplevel.setClipChildren(False)
self.native_toplevel.setClipToPadding(False)
self.native_toplevel.setClipToOutline(False)

You're right: I tried these methods, plus setClipBounds, on self.native, self.native_toplevel, and Container.native_content, and none of them had any effect. I guess whatever mechanism it's using to draw the ripple on the parent's background, it's being clipped by the parent's bounds regardless of any of these settings. Maybe it worked in 2015 but it doesn't work now.

However, the other comment on the StackOverflow question is apparently still true: "The ripple is drawn on the first parent which doesn't have a null background." In the Android API, null and transparent are not the same. If I change set_background_simple to call:

setBackground(None if color in (None, TRANSPARENT) else ColorDrawable(native_color(color)))

Then when the app requests a transparent background, the ripple is drawn correctly on the native parent (the Container), and when the background is non-transparent, it's clipped on the native_toplevel. I would be willing to accept this, since we don't recommend setting backgrounds on most widgets anyway.

But this still doesn't fix the issue where setting a background color on a Box interferes with the rendering of its children. The only solution I can see to that is to actually reproduce the Toga box hierarchy at the native level, rather than making all widgets direct children of the top-level Container. I'm not sure how difficult that would be, but it would be mostly independent of this PR. So I suggest we limit this PR to fixing the child backgrounds, and make a separate PR later if you want to continue dealing with the parent background bug.

@@ -66,6 +64,7 @@ def __init__(self, interface):
)
)

self._default_background_color = Color.TRANSPARENT
Copy link
Member

Choose a reason for hiding this comment

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

self._default_background_color is only used in one place, so it might as well be inlined.

@@ -128,33 +131,20 @@ def set_font(self, font):
# appearance. So each widget must decide how to implement this method, possibly
# using one of the utility functions below.
def set_background_color(self, color):
pass
self.set_background_simple(color)
Copy link
Member

Choose a reason for hiding this comment

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

It should be possible to simplify this even further:

  • Eliminate set_background_simple and move its content into set_background_color.
  • Remove all overrides of set_background_color, except for Button and DIvider.
  • Move the comment about "overwrites other aspects of the widget's appearance" to set_background_filter, and reword it to reflect the current situation.

CACHE = {TRANSPARENT: Color.TRANSPARENT}
CACHE = {
TRANSPARENT: Color.TRANSPARENT,
}
Copy link
Member

Choose a reason for hiding this comment

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

There's no need for this reformatting.

assert self.native.getParent() is container._impl.container.native_content
assert (
self.native_toplevel.getParent() is container._impl.container.native_content
)
Copy link
Member

Choose a reason for hiding this comment

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

This should also check that self.native is either identical to self.native_toplevel, or is a descendant of it.

Comment on lines +105 to +107
# The following complex Drawables all apply color filters to
# their children, but they don't implement getColorFilter, at
# least not in our current minimum API level.
Copy link
Member

Choose a reason for hiding this comment

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

There's no need for this reformatting – we wrap at 88 columns in this repository.

And likewise with the comment below.

@@ -18,7 +18,7 @@ def toga_color(color_int):
Color.red(color_int),
Color.green(color_int),
Color.blue(color_int),
Color.alpha(color_int) / 255,
round(Color.alpha(color_int) / 255, 2),
Copy link
Member

Choose a reason for hiding this comment

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

Rounding error should normally be dealt with by using pytest.approx at the location where the value is compared, not by permanently rounding the value itself.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants