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 createView(View parent, final String name, @NonNull Context context, @NonNull AttributeSet attrs, boolean inheritContext, boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext)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 createTextView(Context context, AttributeSet attrs)176 protected AppCompatTextView createTextView(Context context, AttributeSet attrs) { 177 return new AppCompatTextView(context, attrs); 178 } 179 180 @NonNull createImageView(Context context, AttributeSet attrs)181 protected AppCompatImageView createImageView(Context context, AttributeSet attrs) { 182 return new AppCompatImageView(context, attrs); 183 } 184 185 @NonNull createButton(Context context, AttributeSet attrs)186 protected AppCompatButton createButton(Context context, AttributeSet attrs) { 187 return new AppCompatButton(context, attrs); 188 } 189 190 @NonNull createEditText(Context context, AttributeSet attrs)191 protected AppCompatEditText createEditText(Context context, AttributeSet attrs) { 192 return new AppCompatEditText(context, attrs); 193 } 194 195 @NonNull createSpinner(Context context, AttributeSet attrs)196 protected AppCompatSpinner createSpinner(Context context, AttributeSet attrs) { 197 return new AppCompatSpinner(context, attrs); 198 } 199 200 @NonNull createImageButton(Context context, AttributeSet attrs)201 protected AppCompatImageButton createImageButton(Context context, AttributeSet attrs) { 202 return new AppCompatImageButton(context, attrs); 203 } 204 205 @NonNull createCheckBox(Context context, AttributeSet attrs)206 protected AppCompatCheckBox createCheckBox(Context context, AttributeSet attrs) { 207 return new AppCompatCheckBox(context, attrs); 208 } 209 210 @NonNull createRadioButton(Context context, AttributeSet attrs)211 protected AppCompatRadioButton createRadioButton(Context context, AttributeSet attrs) { 212 return new AppCompatRadioButton(context, attrs); 213 } 214 215 @NonNull createCheckedTextView(Context context, AttributeSet attrs)216 protected AppCompatCheckedTextView createCheckedTextView(Context context, AttributeSet attrs) { 217 return new AppCompatCheckedTextView(context, attrs); 218 } 219 220 @NonNull createAutoCompleteTextView(Context context, AttributeSet attrs)221 protected AppCompatAutoCompleteTextView createAutoCompleteTextView(Context context, 222 AttributeSet attrs) { 223 return new AppCompatAutoCompleteTextView(context, attrs); 224 } 225 226 @NonNull createMultiAutoCompleteTextView(Context context, AttributeSet attrs)227 protected AppCompatMultiAutoCompleteTextView createMultiAutoCompleteTextView(Context context, 228 AttributeSet attrs) { 229 return new AppCompatMultiAutoCompleteTextView(context, attrs); 230 } 231 232 @NonNull createRatingBar(Context context, AttributeSet attrs)233 protected AppCompatRatingBar createRatingBar(Context context, AttributeSet attrs) { 234 return new AppCompatRatingBar(context, attrs); 235 } 236 237 @NonNull createSeekBar(Context context, AttributeSet attrs)238 protected AppCompatSeekBar createSeekBar(Context context, AttributeSet attrs) { 239 return new AppCompatSeekBar(context, attrs); 240 } 241 verifyNotNull(View view, String name)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 createView(Context context, String name, AttributeSet attrs)250 protected View createView(Context context, String name, AttributeSet attrs) { 251 return null; 252 } 253 createViewFromTag(Context context, String name, AttributeSet attrs)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 */ checkOnClickListener(View view, AttributeSet attrs)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 createViewByPrefix(Context context, String name, String prefix)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 */ themifyContext(Context context, AttributeSet attrs, boolean useAndroidTheme, boolean useAppTheme)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 DeclaredOnClickListener(@onNull View hostView, @NonNull String methodName)373 public DeclaredOnClickListener(@NonNull View hostView, @NonNull String methodName) { 374 mHostView = hostView; 375 mMethodName = methodName; 376 } 377 378 @Override onClick(@onNull View v)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 resolveMethod(@ullable Context context, @NonNull String name)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