0

My company's app supports changing the theme dynamically at runtime without having to restart the current Activity. It does this by walking the View tree and applying styles to each View that is configured to support it. The issue I'm running into, however, is that the drop down Views that are displayed for Spinners are never found when walking the View tree. This is the code for finding each View from the Activity:

void applyTheme(final int resourceId) {
    setTheme(resourceId);

    // Apply the theme to the activity's view.
    styleView(findViewById(android.R.id.content));

    // Apply the theme to any dialog fragments.
    final List<Fragment> fragments = getSupportFragmentManager().getFragments();
    for (final Fragment fragment : fragments) {
        if (fragment instanceof DialogFragment) {
            final Dialog dialog = ((DialogFragment)fragment).getDialog();
            if (dialog != null) {
                final Window window = dialog.getWindow();
                if (window != null) {
                    styleView(window.getDecorView());
                }
            }
        }
    }
}

void styleView(final View view) {
    // Apply styling here.
    ...

    // Recursively find all children and apply the theme to them.
    if (view instanceof ViewGroup) {
        final ViewGroup viewGroup = (ViewGroup)view;
        for (int i = 0; i < viewGroup.getChildCount(); i++) {
            styleView(viewGroup.getChildAt(i));
        }
    }
}

The drop down views are not found no matter if the parent Spinner is defined in the Activity, a Fragment, or a DialogFragment. If the drop down Views are visible to the user, is there any way to actually retrieve references to them?

Hei2
  • 44
  • 4
  • 1
    A `Spinner`'s dropdown is actually a `PopupWindow` with a `ListView` in it. A `PopupWindow` is a completely separate window than the `Activity`'s, so its `View` hierarchy is not attached to the `Activity`'s, which is why you don't get those `View`s in your traversal. However, even if you could, I don't think it's going to work out well. Firstly, that `PopupWindow` – and the `ListView` therein – is created anew each time it's shown, so any modifications made directly on those `View`s won't persist. Secondly, I would assume that your theme switch is triggered by some click event somewhere. – Mike M. Apr 10 '20 at 01:36
  • If so, then I wouldn't think that the dropdown will be visible when that happens, since a click on the dropdown, or anywhere else, would cause it to immediately dismiss. Given both of those, I'm thinking a viable option would be to simply set a new `Adapter` instance on the `Spinner`, passing it a `ContextThemeWrapper` around the `Activity`, with the appropriate stylings. Alternatively, you could create a custom `Adapter` class wherein you style each item `View` similarly to how you are already. You wouldn't necessarily need to create a new instance each time, in that case. – Mike M. Apr 10 '20 at 01:36
  • The user has the option both to select a specific theme as well as an option that automatically changes the theme based on the time of day. In the case of the latter, the spinner could possibly be open when the switch occurs, leaving the drop down themed differently than the view the Spinner actually resides in. The View the Spinner returns for the drop down is actually a custom View whose constructor applies styling, so as long as I can update the already instantiated Views, then I don't need to make the adapter apply styling. – Hei2 Apr 10 '20 at 02:40
  • 1
    Ah, OK, I see why that's a concern, then. Well, there's really no clean, non-hacky way to get at those `View`s like you are the rest, as neither that `PopupWindow` nor the `ListView` are publicly exposed. The custom `Adapter` is still a viable option, though, since you can call `notifyDataSetChanged()` on that for the theme switch, and instantiate new dropdown `View`s with the current styling in `getView()` or `getDropDownView()`, depending on whether you have a separate layout for the dropdown items. – Mike M. Apr 10 '20 at 03:32
  • Alright, thank you for the help. If you add an answer for this, I'll gladly mark it accepted. – Hei2 Apr 10 '20 at 03:41
  • 1
    Oh, it's cool. :-) I didn't really do anything except give a very broad outline for a solution. Please feel free to post an answer yourself, when you get something together that works for ya. I'm sure a concrete example will be quite helpful for somebody in the future, what with everybody doing dark/light, `DayNight` theme stuff now. Thank you, though. I really appreciate the offer. Glad I could give ya a few pointers. Cheers! – Mike M. Apr 10 '20 at 03:56
  • I was curious if my suggestion was indeed workable, so I threw together a quick and dirty test project, and discovered a very slight snag. The dropdown `PopupWindow`'s background color depends on the theme, and if it's open when the switch happens, it's not going to update. Setting the item `View`s' backgrounds to a theme color attribute – e.g., `?colorPrimary` – seems to work nicely. Just a heads up. In case it might be of some help, here's the code: https://drive.google.com/file/d/1tJoiirtorHdf48BOyJGG6vSHcqva_5Tw/view?usp=drivesdk. – Mike M. Apr 10 '20 at 05:53

1 Answers1

0

This answer provided me a means to find all Views, including those of PopupWindows. Unfortunately, it makes use of reflection and is not guaranteed to work on all API versions, but it appears to work on the APIs that our app supports (I tested on APIs 19 and 28). This is the result of the changes to applyTheme():

private Object wmgInstance = null;
private Method getViewRootNames = null;
private Method getRootView = null;

private void applyTheme(final int resourceId)
{
    setTheme(resourceId);

    try
    {
        // Find all possible root views (the Activity, any PopupWindows, and Dialogs). This logic should work with APIs 17-28.
        if (wmgInstance == null)
        {
            final Class wmgClass = Class.forName("android.view.WindowManagerGlobal");
            wmgInstance = wmgClass.getMethod("getInstance").invoke(null, (Object[])null);

            getViewRootNames = wmgClass.getMethod("getViewRootNames");
            getRootView = wmgClass.getMethod("getRootView", String.class);
        }

        final String[] rootViewNames = (String[])getViewRootNames.invoke(wmgInstance, (Object[])null);

        for (final String viewName : rootViewNames)
        {
            final View rootView = (View)getRootView.invoke(wmgInstance, viewName);
            styleView(rootView);
        }
    }
    catch (Exception e)
    {
        // Log the exception.
    }
}

This logic successfully allowed me to update the Spinner's drop down Views when they were visible during a theme change. However, there still remained an issue that if the Spinner's drop down Views were created before the theme change occurred, but the drop down was closed at the time of the theme change, then re-opening the Spinner showed the Views using the old theme. The way I handled this was just to call styleView() on the View within my adapter's getDropDownView().

Hei2
  • 44
  • 4