Home | History | Annotate | Download | only in app
      1 /*
      2  * Copyright (C) 2014 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package androidx.appcompat.app;
     18 
     19 import android.content.Context;
     20 import android.content.ContextWrapper;
     21 import android.content.res.TypedArray;
     22 import android.os.Build;
     23 import android.util.AttributeSet;
     24 import android.util.Log;
     25 import android.view.InflateException;
     26 import android.view.View;
     27 
     28 import androidx.annotation.NonNull;
     29 import androidx.annotation.Nullable;
     30 import androidx.appcompat.R;
     31 import androidx.appcompat.view.ContextThemeWrapper;
     32 import androidx.appcompat.widget.AppCompatAutoCompleteTextView;
     33 import androidx.appcompat.widget.AppCompatButton;
     34 import androidx.appcompat.widget.AppCompatCheckBox;
     35 import androidx.appcompat.widget.AppCompatCheckedTextView;
     36 import androidx.appcompat.widget.AppCompatEditText;
     37 import androidx.appcompat.widget.AppCompatImageButton;
     38 import androidx.appcompat.widget.AppCompatImageView;
     39 import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView;
     40 import androidx.appcompat.widget.AppCompatRadioButton;
     41 import androidx.appcompat.widget.AppCompatRatingBar;
     42 import androidx.appcompat.widget.AppCompatSeekBar;
     43 import androidx.appcompat.widget.AppCompatSpinner;
     44 import androidx.appcompat.widget.AppCompatTextView;
     45 import androidx.appcompat.widget.TintContextWrapper;
     46 import androidx.collection.ArrayMap;
     47 import androidx.core.view.ViewCompat;
     48 
     49 import java.lang.reflect.Constructor;
     50 import java.lang.reflect.InvocationTargetException;
     51 import java.lang.reflect.Method;
     52 import java.util.Map;
     53 
     54 /**
     55  * This class is responsible for manually inflating our tinted widgets.
     56  * <p>This class two main responsibilities: the first is to 'inject' our tinted views in place of
     57  * the framework versions in layout inflation; the second is backport the {@code android:theme}
     58  * functionality for any inflated widgets. This include theme inheritance from its parent.
     59  */
     60 public class AppCompatViewInflater {
     61 
     62     private static final Class<?>[] sConstructorSignature = new Class[]{
     63             Context.class, AttributeSet.class};
     64     private static final int[] sOnClickAttrs = new int[]{android.R.attr.onClick};
     65 
     66     private static final String[] sClassPrefixList = {
     67             "android.widget.",
     68             "android.view.",
     69             "android.webkit."
     70     };
     71 
     72     private static final String LOG_TAG = "AppCompatViewInflater";
     73 
     74     private static final Map<String, Constructor<? extends View>> sConstructorMap
     75             = new ArrayMap<>();
     76 
     77     private final Object[] mConstructorArgs = new Object[2];
     78 
     79     final View createView(View parent, final String name, @NonNull Context context,
     80             @NonNull AttributeSet attrs, boolean inheritContext,
     81             boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
     82         final Context originalContext = context;
     83 
     84         // We can emulate Lollipop's android:theme attribute propagating down the view hierarchy
     85         // by using the parent's context
     86         if (inheritContext && parent != null) {
     87             context = parent.getContext();
     88         }
     89         if (readAndroidTheme || readAppTheme) {
     90             // We then apply the theme on the context, if specified
     91             context = themifyContext(context, attrs, readAndroidTheme, readAppTheme);
     92         }
     93         if (wrapContext) {
     94             context = TintContextWrapper.wrap(context);
     95         }
     96 
     97         View view = null;
     98 
     99         // We need to 'inject' our tint aware Views in place of the standard framework versions
    100         switch (name) {
    101             case "TextView":
    102                 view = createTextView(context, attrs);
    103                 verifyNotNull(view, name);
    104                 break;
    105             case "ImageView":
    106                 view = createImageView(context, attrs);
    107                 verifyNotNull(view, name);
    108                 break;
    109             case "Button":
    110                 view = createButton(context, attrs);
    111                 verifyNotNull(view, name);
    112                 break;
    113             case "EditText":
    114                 view = createEditText(context, attrs);
    115                 verifyNotNull(view, name);
    116                 break;
    117             case "Spinner":
    118                 view = createSpinner(context, attrs);
    119                 verifyNotNull(view, name);
    120                 break;
    121             case "ImageButton":
    122                 view = createImageButton(context, attrs);
    123                 verifyNotNull(view, name);
    124                 break;
    125             case "CheckBox":
    126                 view = createCheckBox(context, attrs);
    127                 verifyNotNull(view, name);
    128                 break;
    129             case "RadioButton":
    130                 view = createRadioButton(context, attrs);
    131                 verifyNotNull(view, name);
    132                 break;
    133             case "CheckedTextView":
    134                 view = createCheckedTextView(context, attrs);
    135                 verifyNotNull(view, name);
    136                 break;
    137             case "AutoCompleteTextView":
    138                 view = createAutoCompleteTextView(context, attrs);
    139                 verifyNotNull(view, name);
    140                 break;
    141             case "MultiAutoCompleteTextView":
    142                 view = createMultiAutoCompleteTextView(context, attrs);
    143                 verifyNotNull(view, name);
    144                 break;
    145             case "RatingBar":
    146                 view = createRatingBar(context, attrs);
    147                 verifyNotNull(view, name);
    148                 break;
    149             case "SeekBar":
    150                 view = createSeekBar(context, attrs);
    151                 verifyNotNull(view, name);
    152                 break;
    153             default:
    154                 // The fallback that allows extending class to take over view inflation
    155                 // for other tags. Note that we don't check that the result is not-null.
    156                 // That allows the custom inflater path to fall back on the default one
    157                 // later in this method.
    158                 view = createView(context, name, attrs);
    159         }
    160 
    161         if (view == null && originalContext != context) {
    162             // If the original context does not equal our themed context, then we need to manually
    163             // inflate it using the name so that android:theme takes effect.
    164             view = createViewFromTag(context, name, attrs);
    165         }
    166 
    167         if (view != null) {
    168             // If we have created a view, check its android:onClick
    169             checkOnClickListener(view, attrs);
    170         }
    171 
    172         return view;
    173     }
    174 
    175     @NonNull
    176     protected AppCompatTextView createTextView(Context context, AttributeSet attrs) {
    177         return new AppCompatTextView(context, attrs);
    178     }
    179 
    180     @NonNull
    181     protected AppCompatImageView createImageView(Context context, AttributeSet attrs) {
    182         return new AppCompatImageView(context, attrs);
    183     }
    184 
    185     @NonNull
    186     protected AppCompatButton createButton(Context context, AttributeSet attrs) {
    187         return new AppCompatButton(context, attrs);
    188     }
    189 
    190     @NonNull
    191     protected AppCompatEditText createEditText(Context context, AttributeSet attrs) {
    192         return new AppCompatEditText(context, attrs);
    193     }
    194 
    195     @NonNull
    196     protected AppCompatSpinner createSpinner(Context context, AttributeSet attrs) {
    197         return new AppCompatSpinner(context, attrs);
    198     }
    199 
    200     @NonNull
    201     protected AppCompatImageButton createImageButton(Context context, AttributeSet attrs) {
    202         return new AppCompatImageButton(context, attrs);
    203     }
    204 
    205     @NonNull
    206     protected AppCompatCheckBox createCheckBox(Context context, AttributeSet attrs) {
    207         return new AppCompatCheckBox(context, attrs);
    208     }
    209 
    210     @NonNull
    211     protected AppCompatRadioButton createRadioButton(Context context, AttributeSet attrs) {
    212         return new AppCompatRadioButton(context, attrs);
    213     }
    214 
    215     @NonNull
    216     protected AppCompatCheckedTextView createCheckedTextView(Context context, AttributeSet attrs) {
    217         return new AppCompatCheckedTextView(context, attrs);
    218     }
    219 
    220     @NonNull
    221     protected AppCompatAutoCompleteTextView createAutoCompleteTextView(Context context,
    222             AttributeSet attrs) {
    223         return new AppCompatAutoCompleteTextView(context, attrs);
    224     }
    225 
    226     @NonNull
    227     protected AppCompatMultiAutoCompleteTextView createMultiAutoCompleteTextView(Context context,
    228             AttributeSet attrs) {
    229         return new AppCompatMultiAutoCompleteTextView(context, attrs);
    230     }
    231 
    232     @NonNull
    233     protected AppCompatRatingBar createRatingBar(Context context, AttributeSet attrs) {
    234         return new AppCompatRatingBar(context, attrs);
    235     }
    236 
    237     @NonNull
    238     protected AppCompatSeekBar createSeekBar(Context context, AttributeSet attrs) {
    239         return new AppCompatSeekBar(context, attrs);
    240     }
    241 
    242     private void verifyNotNull(View view, String name) {
    243         if (view == null) {
    244             throw new IllegalStateException(this.getClass().getName()
    245                     + " asked to inflate view for <" + name + ">, but returned null");
    246         }
    247     }
    248 
    249     @Nullable
    250     protected View createView(Context context, String name, AttributeSet attrs) {
    251         return null;
    252     }
    253 
    254     private View createViewFromTag(Context context, String name, AttributeSet attrs) {
    255         if (name.equals("view")) {
    256             name = attrs.getAttributeValue(null, "class");
    257         }
    258 
    259         try {
    260             mConstructorArgs[0] = context;
    261             mConstructorArgs[1] = attrs;
    262 
    263             if (-1 == name.indexOf('.')) {
    264                 for (int i = 0; i < sClassPrefixList.length; i++) {
    265                     final View view = createViewByPrefix(context, name, sClassPrefixList[i]);
    266                     if (view != null) {
    267                         return view;
    268                     }
    269                 }
    270                 return null;
    271             } else {
    272                 return createViewByPrefix(context, name, null);
    273             }
    274         } catch (Exception e) {
    275             // We do not want to catch these, lets return null and let the actual LayoutInflater
    276             // try
    277             return null;
    278         } finally {
    279             // Don't retain references on context.
    280             mConstructorArgs[0] = null;
    281             mConstructorArgs[1] = null;
    282         }
    283     }
    284 
    285     /**
    286      * android:onClick doesn't handle views with a ContextWrapper context. This method
    287      * backports new framework functionality to traverse the Context wrappers to find a
    288      * suitable target.
    289      */
    290     private void checkOnClickListener(View view, AttributeSet attrs) {
    291         final Context context = view.getContext();
    292 
    293         if (!(context instanceof ContextWrapper) ||
    294                 (Build.VERSION.SDK_INT >= 15 && !ViewCompat.hasOnClickListeners(view))) {
    295             // Skip our compat functionality if: the Context isn't a ContextWrapper, or
    296             // the view doesn't have an OnClickListener (we can only rely on this on API 15+ so
    297             // always use our compat code on older devices)
    298             return;
    299         }
    300 
    301         final TypedArray a = context.obtainStyledAttributes(attrs, sOnClickAttrs);
    302         final String handlerName = a.getString(0);
    303         if (handlerName != null) {
    304             view.setOnClickListener(new DeclaredOnClickListener(view, handlerName));
    305         }
    306         a.recycle();
    307     }
    308 
    309     private View createViewByPrefix(Context context, String name, String prefix)
    310             throws ClassNotFoundException, InflateException {
    311         Constructor<? extends View> constructor = sConstructorMap.get(name);
    312 
    313         try {
    314             if (constructor == null) {
    315                 // Class not found in the cache, see if it's real, and try to add it
    316                 Class<? extends View> clazz = context.getClassLoader().loadClass(
    317                         prefix != null ? (prefix + name) : name).asSubclass(View.class);
    318 
    319                 constructor = clazz.getConstructor(sConstructorSignature);
    320                 sConstructorMap.put(name, constructor);
    321             }
    322             constructor.setAccessible(true);
    323             return constructor.newInstance(mConstructorArgs);
    324         } catch (Exception e) {
    325             // We do not want to catch these, lets return null and let the actual LayoutInflater
    326             // try
    327             return null;
    328         }
    329     }
    330 
    331     /**
    332      * Allows us to emulate the {@code android:theme} attribute for devices before L.
    333      */
    334     private static Context themifyContext(Context context, AttributeSet attrs,
    335             boolean useAndroidTheme, boolean useAppTheme) {
    336         final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.View, 0, 0);
    337         int themeId = 0;
    338         if (useAndroidTheme) {
    339             // First try reading android:theme if enabled
    340             themeId = a.getResourceId(R.styleable.View_android_theme, 0);
    341         }
    342         if (useAppTheme && themeId == 0) {
    343             // ...if that didn't work, try reading app:theme (for legacy reasons) if enabled
    344             themeId = a.getResourceId(R.styleable.View_theme, 0);
    345 
    346             if (themeId != 0) {
    347                 Log.i(LOG_TAG, "app:theme is now deprecated. "
    348                         + "Please move to using android:theme instead.");
    349             }
    350         }
    351         a.recycle();
    352 
    353         if (themeId != 0 && (!(context instanceof ContextThemeWrapper)
    354                 || ((ContextThemeWrapper) context).getThemeResId() != themeId)) {
    355             // If the context isn't a ContextThemeWrapper, or it is but does not have
    356             // the same theme as we need, wrap it in a new wrapper
    357             context = new ContextThemeWrapper(context, themeId);
    358         }
    359         return context;
    360     }
    361 
    362     /**
    363      * An implementation of OnClickListener that attempts to lazily load a
    364      * named click handling method from a parent or ancestor context.
    365      */
    366     private static class DeclaredOnClickListener implements View.OnClickListener {
    367         private final View mHostView;
    368         private final String mMethodName;
    369 
    370         private Method mResolvedMethod;
    371         private Context mResolvedContext;
    372 
    373         public DeclaredOnClickListener(@NonNull View hostView, @NonNull String methodName) {
    374             mHostView = hostView;
    375             mMethodName = methodName;
    376         }
    377 
    378         @Override
    379         public void onClick(@NonNull View v) {
    380             if (mResolvedMethod == null) {
    381                 resolveMethod(mHostView.getContext(), mMethodName);
    382             }
    383 
    384             try {
    385                 mResolvedMethod.invoke(mResolvedContext, v);
    386             } catch (IllegalAccessException e) {
    387                 throw new IllegalStateException(
    388                         "Could not execute non-public method for android:onClick", e);
    389             } catch (InvocationTargetException e) {
    390                 throw new IllegalStateException(
    391                         "Could not execute method for android:onClick", e);
    392             }
    393         }
    394 
    395         @NonNull
    396         private void resolveMethod(@Nullable Context context, @NonNull String name) {
    397             while (context != null) {
    398                 try {
    399                     if (!context.isRestricted()) {
    400                         final Method method = context.getClass().getMethod(mMethodName, View.class);
    401                         if (method != null) {
    402                             mResolvedMethod = method;
    403                             mResolvedContext = context;
    404                             return;
    405                         }
    406                     }
    407                 } catch (NoSuchMethodException e) {
    408                     // Failed to find method, keep searching up the hierarchy.
    409                 }
    410 
    411                 if (context instanceof ContextWrapper) {
    412                     context = ((ContextWrapper) context).getBaseContext();
    413                 } else {
    414                     // Can't search up the hierarchy, null out and fail.
    415                     context = null;
    416                 }
    417             }
    418 
    419             final int id = mHostView.getId();
    420             final String idText = id == View.NO_ID ? "" : " with id '"
    421                     + mHostView.getContext().getResources().getResourceEntryName(id) + "'";
    422             throw new IllegalStateException("Could not find method " + mMethodName
    423                     + "(View) in a parent or ancestor Context for android:onClick "
    424                     + "attribute defined on view " + mHostView.getClass() + idText);
    425         }
    426     }
    427 }
    428