Skip to content

Commit a7a64f0

Browse files
feat(ui): add custom arrow shape
1 parent 01c1130 commit a7a64f0

21 files changed

Lines changed: 816 additions & 572 deletions

V2rayNG/app/src/main/java/com/neko/widget/DynamicShapeImageView.kt

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,42 @@ import com.v2ray.ang.R
1616
import com.v2ray.ang.handler.MmkvManager
1717
import com.v2ray.ang.util.getColorAttr
1818

19+
/**
20+
* Shape-aware ImageView used both for the app's icon-shape background badges
21+
* and, via [R.styleable.DynamicShapeImageView_shapeTarget], for the small
22+
* "arrow" background badges shown at the end of preference/menu cards.
23+
*
24+
* Both targets share the exact same set of SVG shapes (see [resolveShapeId]),
25+
* but each target has its own MMKV pref key / broadcast action so the icon
26+
* shape and arrow shape can be customized independently from UI Settings.
27+
*/
1928
class DynamicShapeImageView @JvmOverloads constructor(
2029
context: Context,
2130
attrs: AttributeSet? = null,
2231
defStyleAttr: Int = 0
2332
) : ShaderImageView(context, attrs, defStyleAttr) {
2433

25-
private var currentShapeKey: String? = AppConfig.PREF_ICON_SHAPE_DEFAULT
34+
private enum class ShapeTarget { ICON, ARROW }
35+
36+
private var shapeTarget: ShapeTarget = ShapeTarget.ICON
37+
38+
private val prefKey: String
39+
get() = if (shapeTarget == ShapeTarget.ARROW) AppConfig.PREF_ARROW_SHAPE else AppConfig.PREF_ICON_SHAPE
40+
41+
private val defaultShapeKey: String
42+
get() = if (shapeTarget == ShapeTarget.ARROW) AppConfig.PREF_ARROW_SHAPE_DEFAULT else AppConfig.PREF_ICON_SHAPE_DEFAULT
43+
44+
private val broadcastAction: String
45+
get() = if (shapeTarget == ShapeTarget.ARROW) AppConfig.BROADCAST_ACTION_ARROW_SHAPE_CHANGED else AppConfig.BROADCAST_ACTION_ICON_SHAPE_CHANGED
46+
47+
private var currentShapeKey: String? = null
2648

2749
private var customBgColor: Int? = null
2850

2951
private val shapeChangeReceiver = object : BroadcastReceiver() {
3052
override fun onReceive(ctx: Context?, intent: Intent?) {
31-
if (intent?.action == AppConfig.BROADCAST_ACTION_ICON_SHAPE_CHANGED) {
32-
val newKey = MmkvManager.decodeSettingsString(AppConfig.PREF_ICON_SHAPE)
33-
?: AppConfig.PREF_ICON_SHAPE_DEFAULT
53+
if (intent?.action == broadcastAction) {
54+
val newKey = MmkvManager.decodeSettingsString(prefKey) ?: defaultShapeKey
3455
applyShape(newKey)
3556
}
3657
}
@@ -56,10 +77,28 @@ class DynamicShapeImageView @JvmOverloads constructor(
5677
)
5778
}
5879

80+
shapeTarget = if (typedArray.getInt(R.styleable.DynamicShapeImageView_shapeTarget, 0) == 1) {
81+
ShapeTarget.ARROW
82+
} else {
83+
ShapeTarget.ICON
84+
}
85+
5986
typedArray.recycle()
6087
}
6188

89+
currentShapeKey = defaultShapeKey
90+
6291
scaleType = ScaleType.CENTER_CROP
92+
93+
// ShaderImageView's own init block already called createImageViewHelper()
94+
// during the super() constructor call above, i.e. before shapeTarget and
95+
// customBgColor (parsed just above) even existed yet — so that first
96+
// helper was always built with the ICON defaults. Force a rebuild now
97+
// that this view's real target/color are known, so arrow badges (and any
98+
// non-default color) render correctly from the very first frame instead
99+
// of only after the user actively changes the shape preference.
100+
reloadShape()
101+
63102
loadColorBitmap()
64103
}
65104

@@ -80,11 +119,10 @@ class DynamicShapeImageView @JvmOverloads constructor(
80119
override fun onAttachedToWindow() {
81120
super.onAttachedToWindow()
82121
if (!isInEditMode) {
83-
val savedKey = MmkvManager.decodeSettingsString(AppConfig.PREF_ICON_SHAPE)
84-
?: AppConfig.PREF_ICON_SHAPE_DEFAULT
122+
val savedKey = MmkvManager.decodeSettingsString(prefKey) ?: defaultShapeKey
85123
applyShape(savedKey)
86124

87-
val filter = IntentFilter(AppConfig.BROADCAST_ACTION_ICON_SHAPE_CHANGED)
125+
val filter = IntentFilter(broadcastAction)
88126
ContextCompat.registerReceiver(
89127
context, shapeChangeReceiver, filter,
90128
ContextCompat.RECEIVER_NOT_EXPORTED
@@ -103,8 +141,7 @@ class DynamicShapeImageView @JvmOverloads constructor(
103141
super.onWindowFocusChanged(hasWindowFocus)
104142

105143
if (hasWindowFocus && !isInEditMode) {
106-
val savedKey = MmkvManager.decodeSettingsString(AppConfig.PREF_ICON_SHAPE)
107-
?: AppConfig.PREF_ICON_SHAPE_DEFAULT
144+
val savedKey = MmkvManager.decodeSettingsString(prefKey) ?: defaultShapeKey
108145
applyShape(savedKey)
109146
}
110147
}
@@ -117,7 +154,8 @@ class DynamicShapeImageView @JvmOverloads constructor(
117154
}
118155
}
119156

120-
private fun resolveShapeId(): Int = when (currentShapeKey ?: AppConfig.PREF_ICON_SHAPE_DEFAULT) {
157+
private fun resolveShapeId(): Int = when (currentShapeKey ?: defaultShapeKey) {
158+
"uwu_shape_cookie" -> R.raw.uwu_shape_cookie
121159
"uwu_shape_clover" -> R.raw.uwu_shape_clover
122160
"uwu_shape_circle" -> R.raw.uwu_shape_circle
123161
"uwu_shape_diamond" -> R.raw.uwu_shape_diamond
@@ -127,9 +165,9 @@ class DynamicShapeImageView @JvmOverloads constructor(
127165
"uwu_shape_rounded_square" -> R.raw.uwu_shape_rounded_square
128166
"uwu_shape_squircle" -> R.raw.uwu_shape_squircle
129167
"uwu_shape_heart" -> R.raw.uwu_shape_heart
130-
"uwu_shape_hive" -> R.raw.uwu_shape_hive
131-
"uwu_shape_pill" -> R.raw.uwu_shape_pill
132-
"uwu_shape_scallop" -> R.raw.uwu_shape_scallop
133-
else -> R.raw.uwu_shape_cookie
168+
"uwu_shape_hive" -> R.raw.uwu_shape_hive
169+
"uwu_shape_pill" -> R.raw.uwu_shape_pill
170+
"uwu_shape_scallop" -> R.raw.uwu_shape_scallop
171+
else -> if (shapeTarget == ShapeTarget.ARROW) R.raw.uwu_shape_circle else R.raw.uwu_shape_cookie
134172
}
135173
}

V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ object AppConfig {
134134
const val DEFAULT_BLUR_BOTTOM_ALPHA = 50
135135
const val PREF_ICON_SHAPE = "pref_icon_shape"
136136
const val PREF_ICON_SHAPE_DEFAULT = "uwu_shape_cookie"
137+
const val PREF_ARROW_SHAPE = "pref_arrow_shape"
138+
const val PREF_ARROW_SHAPE_DEFAULT = "uwu_shape_cookie"
137139

138140
const val PREF_APP_ICON = "pref_app_icon"
139141
const val APP_ICON_DEFAULT = "default"
@@ -295,6 +297,7 @@ object AppConfig {
295297
const val BROADCAST_ACTION_ACTIVITY = "$ANG_PACKAGE.action.activity"
296298
const val BROADCAST_ACTION_WIDGET_CLICK = "$ANG_PACKAGE.action.widget.click"
297299
const val BROADCAST_ACTION_ICON_SHAPE_CHANGED = "$ANG_PACKAGE.action.icon_shape_changed"
300+
const val BROADCAST_ACTION_ARROW_SHAPE_CHANGED = "$ANG_PACKAGE.action.arrow_shape_changed"
298301
const val BROADCAST_ACTION_PROFILE_BANNER_CHANGED = "$ANG_PACKAGE.action.profile_banner_changed"
299302
const val BROADCAST_ACTION_HOME_BANNER_CHANGED = "$ANG_PACKAGE.action.home_banner_changed"
300303
const val BROADCAST_ACTION_HEADER_TOP_ROW_PADDING_CHANGED = "$ANG_PACKAGE.action.header_top_row_padding_changed"

V2rayNG/app/src/main/java/com/v2ray/ang/ui/preference/activity/UiSettingsActivity.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ class UiSettingsActivity : BaseActivity() {
9999
private val appLanguage by lazy { findPreference<ListPreference>(AppConfig.PREF_LANGUAGE) }
100100
private val nightTheme by lazy { findPreference<ListPreference>(AppConfig.PREF_UI_MODE_NIGHT) }
101101
private val iconShape by lazy { findPreference<ListPreference>(AppConfig.PREF_ICON_SHAPE) }
102+
private val arrowShape by lazy { findPreference<ListPreference>(AppConfig.PREF_ARROW_SHAPE) }
102103
private val appIcon by lazy { findPreference<com.v2ray.ang.ui.dialog.AppIconPickerDialog>(AppConfig.PREF_APP_ICON) }
103104
private val customAppName by lazy { findPreference<ListPreference>(AppConfig.PREF_CUSTOM_APP_NAME) }
104105
private val customDpi by lazy { findPreference<DpiSliderDialog>(AppConfig.PREF_CUSTOM_DPI) }
@@ -437,6 +438,20 @@ class UiSettingsActivity : BaseActivity() {
437438
true
438439
}
439440

441+
arrowShape?.setOnPreferenceChangeListener { pref, newValue ->
442+
val valueStr = newValue.toString()
443+
(pref as? ListPreference)?.let { lp ->
444+
val idx = lp.findIndexOfValue(valueStr)
445+
lp.summary = if (idx >= 0) lp.entries[idx] else valueStr
446+
}
447+
requireContext().sendBroadcast(
448+
android.content.Intent(AppConfig.BROADCAST_ACTION_ARROW_SHAPE_CHANGED).apply {
449+
putExtra(AppConfig.PREF_ARROW_SHAPE, valueStr.ifEmpty { AppConfig.PREF_ARROW_SHAPE_DEFAULT })
450+
}
451+
)
452+
true
453+
}
454+
440455
appIcon?.setOnPreferenceChangeListener { _, _ ->
441456
requireContext().toastSuccess(getString(R.string.app_icon_updated))
442457
true

0 commit comments

Comments
 (0)