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 13 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
Copy link
Member

mhsmith commented Feb 3, 2025

DetailedList widget now correctly unsets the highlight color from an unselected item

This is a good improvement, but I think it's independent of the other issues. So I suggest moving it to a separate PR, and fixing the corresponding problem in Table at the same time.

setting a custom background_color on the Selection widget, now also sets the background color of the dropdown popup

This probably isn't a good idea, because the two elements will appear against different backgrounds. For example, if you set the background color to transparent, then you get this:

I think it's better to treat the popup as a system-provided dialog, like the long-press menu on a TextInput, and not attempt to style it at all.

@mhsmith
Copy link
Member

mhsmith commented Feb 3, 2025

There's some inconsistency in whether the default background color is transparent or white:

My inclination is that everything should default to transparent (except buttons, obviously), because this version of the Android visual style doesn't really emphasize the rectangular borders of widgets.

This would allow the removal of DEFAULT_BACKGROUND_COLOR and toga_color from colors.py.

It may also allow a significant reduction in the number of set_background_color overrides in the subclasses, and maybe even allow the method to be implemented in the Widget base class.

But I haven't thought through all the implications of this, so if you disagree, let's discuss it before you make any changes.

@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 54 to 57
if isinstance(widget, ContainedWidget):
self.native_content.addView(widget.native_widget_container)
else:
self.native_content.addView(widget.native)
Copy link
Member

Choose a reason for hiding this comment

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

Most of the places you're calling isinstance(widget, ContainedWidget), it's only to switch between widget.native and widget.native_widget_container. It would be cleaner to factor this out:

  • Rename native_widget_container to native_toplevel
  • Make Widget implement native_toplevel by returning native.
  • Change all of these locations to use native_toplevel unconditionally.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, this simplified the codebase so much!

Comment on lines 216 to 218
class ContainedWidget(Widget):
def __init__(self, interface):

Copy link
Member

Choose a reason for hiding this comment

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

Please refactor to avoid duplicating the whole of Widget .__init__.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have refactored it out and have removed all of the duplicated operations.

android/src/toga_android/widgets/base.py Outdated Show resolved Hide resolved
Comment on lines 242 to 244
def get_enabled(self):
return self.native.isEnabled() and self.native_widget_container.isEnabled()

Copy link
Member

Choose a reason for hiding this comment

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

Are the enabled and focus overrides really necessary?

I agree we need something for hidden, but could this be done by changing the Widget base class implementation to use native_toplevel as discussed above?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Are the enabled and focus overrides really necessary?

I agree we need something for hidden, but could this be done by changing the Widget base class implementation to use native_toplevel as discussed above?

They weren't necessary, so I have removed these duplicated methods.

@@ -96,7 +95,7 @@ def onRefresh(self):
self._interface.on_refresh()


class DetailedList(Widget):
class DetailedList(ContainedWidget):
Copy link
Member

Choose a reason for hiding this comment

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

Do DetailedList and ProgressBar need to be ContainedWidget subclasses? I don't think either of them has any special background effects.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree, they don't need to be ContainedWidget, so I have removed these changes.

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
Copy link
Contributor Author

DetailedList widget now correctly unsets the highlight color from an unselected item

This is a good improvement, but I think it's independent of the other issues. So I suggest moving it to a separate PR, and fixing >the corresponding problem in Table at the same time.

I have moved it to #3156.

setting a custom background_color on the Selection widget, now also sets the background color of the dropdown popup

This probably isn't a good idea, because the two elements will appear against different backgrounds. For example, if you set the >background color to transparent, then you get this:

I have removed these changes.

My inclination is that everything should default to transparent (except buttons, obviously), because this version of the Android visual style doesn't really emphasize the rectangular borders of widgets.

This would allow the removal of DEFAULT_BACKGROUND_COLOR and toga_color from colors.py.

It may also allow a significant reduction in the number of set_background_color overrides in the subclasses, and maybe even allow the method to be implemented in the Widget base class.

But I haven't thought through all the implications of this, so if you disagree, let's discuss it before you make any changes.

I totally agree with you, the default background color should be transparent, as is the case on most platforms. Hence, I have made transparent the default background color.

@proneon267
Copy link
Contributor Author

proneon267 commented Feb 4, 2025

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

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).

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.

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