自定义LoadingButton阴影被裁剪 求批量设置clip参数方案
我基于FrameLayout实现了自定义LoadingButton组件,可直接在XML中使用,本质是带独立布局的自定义Button。当前为按钮添加elevation、translationZ阴影时,阴影始终出现被裁剪的情况。需求是让按钮能动态调整边距、在不同屏幕保持固定宽高且阴影不被扭曲裁剪,但部分页面仅设置了marginTop未设置marginBottom,即便在按钮内部设置clipToPadding和clipChildren为false,仍存在裁剪问题。现寻求无需修改80余个XML文件,即可批量为所有使用该按钮的页面设置clipToPadding=false和clipChildren=false的方案。
相关代码
自定义按钮XML布局
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <variable name="CustomRes" type="com.project.utils.CustomResources"/> </data> <merge android:duplicateParentState="true" android:layout_width="wrap_content" android:layout_height="wrap_content"> <LinearLayout android:id="@+id/buttonParent" android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_gravity="center" android:duplicateParentState="true" android:clipToPadding="false" android:clipChildren="false" android:gravity="center"> <ImageView android:id="@+id/buttonIcon" android:layout_width="wrap_content" android:layout_height="match_parent" android:adjustViewBounds="true" android:layout_gravity="center" android:scaleType="fitCenter"/> <TextView android:id="@+id/buttonText" android:layout_width="wrap_content" android:layout_height="wrap_content" android:duplicateParentState="true" android:textSize="@dimen/text_medium" android:textStyle="bold" android:lines="1" android:layout_gravity="center"/> </LinearLayout> <com.project.components.loading_indicator.LoadingIndicator android:id="@+id/progress" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:gravity="center" /> </merge> </layout>
自定义按钮实现类
@Suppress("DEPRECATION") class LoadingButton : FrameLayout { @StyleableRes internal val btnIcoIndex = 0 @SuppressLint("ResourceType") @StyleableRes internal val btnTextResIndex = 1 @StyleableRes @SuppressLint("ResourceType") internal val btnTextIndex = 2 @StyleableRes @SuppressLint("ResourceType") internal val btnTextSizeIndex = 3 private val buttonParent: LinearLayout private val progressBar: LoadingIndicator private val buttonIcon: ImageView private val buttonText: TextView private var buttonTextVal: String? = null private var textViewInitWidth = 0 init { CustomResources.inflateLayout(LayoutInflater.from(context), R.layout.loading_button, this) buttonParent = findViewById(R.id.buttonParent) buttonIcon = findViewById(R.id.buttonIcon) buttonText = findViewById(R.id.buttonText) progressBar = findViewById(R.id.progress) buttonText.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> textViewInitWidth = maxOf(v.measuredWidth, textViewInitWidth) } } @SuppressLint("ClickableViewAccessibility") constructor(context: Context) : super(context) { parseAttrs(context) } @SuppressLint("ClickableViewAccessibility") constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { parseAttrs(context, attrs) } @SuppressLint("ClickableViewAccessibility") constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super( context, attrs, defStyleAttr ) { parseAttrs(context, attrs, defStyleAttr) } @SuppressLint("ResourceType") private fun parseAttrs(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int? = null) { setLoading(false) val sets = intArrayOf(R.attr.l_buttonIcon, R.attr.l_buttonTextId, R.attr.l_buttonText, R.attr.l_buttonTextSize) if (attrs != null){ val typedArray = context.obtainStyledAttributes(attrs, sets) val buttonIco = typedArray.getResourceId(btnIcoIndex, 0) val buttonTxtRes = typedArray.getText(btnTextResIndex)?.let { res -> App.getString(res as String).toLowerCase(Locale.getDefault()).replaceFirstChar { it.toUpperCase() } } val buttonTxtRaw = typedArray.getText(btnTextIndex)?.let { it as String }?: "null" val buttonTxtSize = typedArray.getDimension(btnTextSizeIndex, resources.getDimension(R.dimen.text_medium)) val buttonStyle = attrs.styleAttribute App.log("RawTextButton: $buttonTxtRaw, hasValue: ${typedArray.getString(btnTextIndex)}") setButtonTextSize(buttonTxtSize) buttonTxtRes?.let { setButtonText(buttonTxtRes) }?:kotlin.run{ setButtonText(buttonTxtRaw) } setButtonIcon(buttonIco) buttonParent.gravity = Gravity.CENTER buttonTextVal = buttonTxtRes?.let { buttonTxtRes.toString() }?:buttonTxtRaw typedArray.recycle() clipToPadding = false clipChildren = false when(buttonStyle){ R.style.button_primary -> { App.log("BtnStyleId - primary") setupTextStyle(buttonStyle) setProgBarColor(R.color.button_light) setIconTint(ContextCompat.getColor(context, R.color.button_light)) maybeSetAmbientShadow(R.color.button_primary) } R.style.button_secondary -> { App.log("BtnStyleId - secondary") setupTextStyle(buttonStyle) setProgBarColor(R.color.button_primary) setIconTint(ContextCompat.getColor(context, R.color.button_light)) maybeSetAmbientShadow(R.color.button_primary) } R.style.button_secondary_alert -> { App.log("BtnStyleId - secondary alert") setupTextStyle(buttonStyle) setProgBarColor(R.color.button_invalid) setIconTint(ContextCompat.getColor(context, R.color.button_invalid)) maybeSetAmbientShadow(R.color.button_invalid) } else -> { App.log("BtnStyleId -> $buttonStyle") } } } else { App.log("BtnStyleId -> attrs==null") } } @SuppressLint("ResourceType") private fun setupTextStyle(buttonStyle: Int){ val attrs = intArrayOf(android.R.attr.textAppearance) val typedAttrs = context.obtainStyledAttributes(buttonStyle, attrs) val textAppearance = typedAttrs.getResourceId(0, 0) TextViewCompat.setTextAppearance(buttonText, textAppearance) typedAttrs.recycle() } private fun setProgBarColor(color: Int){ progressBar.setColorTint(color) } fun setIconTint(color: Int){ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q){ buttonIcon.colorFilter = BlendModeColorFilter(color, BlendMode.SRC_IN) } else { App.log("setting color filter") buttonIcon.setColorFilter(color, PorterDuff.Mode.SRC_IN) } } private fun maybeSetAmbientShadow(color: Int){ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P){ outlineAmbientShadowColor = ContextCompat.getColor(this.context, color) outlineSpotShadowColor = ContextCompat.getColor(this.context, color) } } private fun setLoading(isLoading: Boolean) { buttonParent.visibility = if (isLoading) View.GONE else View.VISIBLE progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE } private fun setButtonText(text: String) { buttonText.text = text } private fun setButtonIcon(resId: Int) { if (resId != 0) { buttonIcon.setImageResource(resId) } else { buttonIcon.visibility = View.GONE } } private fun setButtonTextSize(size: Float) { buttonText.textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, size, resources.displayMetrics) } }
按钮基础样式
<style name="button" parent="@android:style/Widget.Material.Button"> <item name="android:layout_width">wrap_content</item> <item name="android:layout_height">wrap_content</item> <item name="android:minHeight">0dp</item> <item name="android:minWidth">0dp</item> <item name="android:paddingStart">@dimen/button_padding_start</item> <item name="android:paddingEnd">@dimen/button_padding_end</item> <item name="android:paddingTop">@dimen/button_padding_top</item> <item name="android:paddingBottom">@dimen/button_padding_bottom</item> <item name="android:elevation">4dp</item> <item name="android:translationZ">4dp</item> <item name="android:stateListAnimator">@null</item> <item name="android:clipToPadding">false</item> </style>
组件使用示例
<com.project.components.loading_button.LoadingButton android:id="@+id/loginButton" android:layout_gravity="center" android:layout_weight="0.5" android:layout_marginStart="8dp" app:l_buttonTextId="button_login" style="@style/button.primary" />
批量解决方案
方案1:在自定义View中强制修改父容器属性
阴影被裁剪的核心原因通常是按钮的直接父容器开启了裁剪,而非按钮自身。因此可以在LoadingButton中添加逻辑,当按钮附加到窗口时,自动修改父容器的裁剪属性:
override fun onAttachedToWindow() { super.onAttachedToWindow() // 获取直接父容器 val parentView = parent as? ViewGroup parentView?.apply { clipToPadding = false clipChildren = false // 若父容器的父容器也可能裁剪,可按需递归处理 val grandParent = parent as? ViewGroup grandParent?.apply { clipToPadding = false clipChildren = false } } }
这种方式无需修改任何XML,所有使用LoadingButton的页面都会自动生效,彻底解决批量修改的问题。
方案2:通过主题全局设置布局默认属性
如果项目中按钮的父容器多为固定类型(如LinearLayout、ConstraintLayout),可以在App主题中为这些布局设置默认的裁剪属性:
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar"> <!-- 给LinearLayout设置默认属性 --> <item name="android:LinearLayoutStyle">@style/ClippingDisabledLinearLayout</item> <!-- 给ConstraintLayout设置默认属性 --> <item name="android:ConstraintLayoutStyle">@style/ClippingDisabledConstraintLayout</item> </style> <style name="ClippingDisabledLinearLayout" parent="Widget.MaterialComponents.LinearLayout"> <item name="android:clipToPadding">false</item> <item name="android:clipChildren">false</item> </style> <style name="ClippingDisabledConstraintLayout" parent="Widget.MaterialComponents.ConstraintLayout"> <item name="android:clipToPadding">false</item> <item name="android:clipChildren">false</item> </style>
注意:该方案会影响所有对应类型的布局,若有部分布局需要保留裁剪功能,需单独设置属性覆盖全局配置。
方案3:添加安全边距避免阴影被裁剪
除了关闭裁剪,还可以在LoadingButton中自动添加与阴影大小匹配的边距,确保阴影不会超出父容器范围:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) // 获取阴影大小(elevation值),转换为实际像素 val shadowSize = elevation.toInt() // 给按钮添加额外边距,避免阴影被裁剪 setPadding( paddingLeft + shadowSize, paddingTop + shadowSize, paddingRight + shadowSize, paddingBottom + shadowSize ) }
该方案可与方案1结合使用,双重保障阴影不被裁剪。
内容的提问来源于stack exchange,提问作者Martin
相关产品推荐
相关产品推荐

