Skip to content

Commit 638156d

Browse files
dsn5ftpekingme
authored andcommitted
[FAB Menu] Add Catalog demo to show using Compose FAB Menu in Views via interop
Related to #4777 PiperOrigin-RevId: 759648847
1 parent 08de7b0 commit 638156d

File tree

10 files changed

+432
-2
lines changed

10 files changed

+432
-2
lines changed

catalog/build.gradle

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
1-
apply plugin: 'com.android.application'
1+
buildscript {
2+
repositories {
3+
google()
4+
mavenCentral()
5+
}
6+
dependencies {
7+
classpath 'org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:2.1.0'
8+
}
9+
}
10+
11+
plugins {
12+
id 'com.android.application'
13+
id 'org.jetbrains.kotlin.android' version "2.1.21"
14+
id 'org.jetbrains.kotlin.plugin.compose' version "2.1.0"
15+
}
216

317
dependencies {
418

519
// Align kotlin versions
6-
implementation(platform(libs.kotlin.bom))
20+
implementation(platform('org.jetbrains.kotlin:kotlin-bom:2.1.0'))
721

822
api libs.dagger
923
annotationProcessor libs.dagger.compiler
@@ -20,6 +34,11 @@ dependencies {
2034
api libs.androidx.window.java
2135
api libs.androidx.preference
2236

37+
api libs.androidx.activity.compose
38+
api libs.androidx.compose.material.icons.core
39+
api libs.androidx.compose.material.icons.extended
40+
api libs.androidx.compose.material3
41+
2342
api libs.guava
2443
modules {
2544
module("com.google.guava:listenablefuture") {
@@ -133,6 +152,13 @@ android {
133152
java.excludes = [
134153
'**/build/**',
135154
]
155+
kotlin.srcDir 'java'
156+
kotlin.includes = srcDirs.collect {
157+
'io/material/catalog/' + it + '/**/*.kt'
158+
}
159+
kotlin.excludes = [
160+
'**/build/**',
161+
]
136162
res.srcDirs = ['java/io/material/catalog/res']
137163
srcDirs.forEach {
138164
res.srcDirs += 'java/io/material/catalog/' + it + '/res'
@@ -147,6 +173,12 @@ android {
147173
sourceCompatibility JavaVersion.VERSION_1_8
148174
targetCompatibility JavaVersion.VERSION_1_8
149175
}
176+
kotlinOptions {
177+
jvmTarget = "1.8"
178+
}
179+
buildFeatures {
180+
compose true
181+
}
150182
androidResources {
151183
additionalParameters '--no-version-vectors'
152184
}

catalog/java/io/material/catalog/fab/FabFragment.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ public Fragment createFragment() {
7070
return new ExtendedFabBehaviorDemoFragment();
7171
}
7272
});
73+
additionalDemos.add(
74+
new Demo(R.string.cat_fab_menu_demo_title) {
75+
@Override
76+
public Fragment createFragment() {
77+
return new FabMenuDemoFragment();
78+
}
79+
});
7380
return additionalDemos;
7481
}
7582

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
/*
2+
* Copyright 2025 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+
* https://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+
package io.material.catalog.fab
17+
18+
import io.material.catalog.R
19+
20+
import android.annotation.SuppressLint
21+
import android.os.Bundle
22+
import android.view.LayoutInflater
23+
import android.view.View
24+
import android.view.ViewGroup
25+
import android.widget.LinearLayout
26+
import android.widget.TextView
27+
import androidx.activity.compose.BackHandler
28+
import androidx.compose.foundation.isSystemInDarkTheme
29+
import androidx.compose.foundation.layout.Box
30+
import androidx.compose.foundation.layout.WindowInsets
31+
import androidx.compose.foundation.layout.navigationBars
32+
import androidx.compose.foundation.layout.windowInsetsPadding
33+
import androidx.compose.foundation.lazy.rememberLazyListState
34+
import androidx.compose.material.icons.Icons
35+
import androidx.compose.material.icons.automirrored.filled.Label
36+
import androidx.compose.material.icons.automirrored.filled.Message
37+
import androidx.compose.material.icons.filled.Add
38+
import androidx.compose.material.icons.filled.Archive
39+
import androidx.compose.material.icons.filled.Close
40+
import androidx.compose.material.icons.filled.Contacts
41+
import androidx.compose.material.icons.filled.People
42+
import androidx.compose.material.icons.filled.Snooze
43+
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
44+
import androidx.compose.material3.FloatingActionButtonMenu
45+
import androidx.compose.material3.FloatingActionButtonMenuItem
46+
import androidx.compose.material3.Icon
47+
import androidx.compose.material3.MaterialExpressiveTheme
48+
import androidx.compose.material3.Text
49+
import androidx.compose.material3.ToggleFloatingActionButton
50+
import androidx.compose.material3.ToggleFloatingActionButtonDefaults.animateIcon
51+
import androidx.compose.material3.animateFloatingActionButton
52+
import androidx.compose.material3.darkColorScheme
53+
import androidx.compose.material3.dynamicDarkColorScheme
54+
import androidx.compose.material3.dynamicLightColorScheme
55+
import androidx.compose.material3.expressiveLightColorScheme
56+
import androidx.compose.runtime.Composable
57+
import androidx.compose.runtime.LaunchedEffect
58+
import androidx.compose.runtime.derivedStateOf
59+
import androidx.compose.runtime.getValue
60+
import androidx.compose.runtime.mutableStateOf
61+
import androidx.compose.runtime.remember
62+
import androidx.compose.runtime.saveable.rememberSaveable
63+
import androidx.compose.runtime.setValue
64+
import androidx.compose.ui.Alignment
65+
import androidx.compose.ui.Modifier
66+
import androidx.compose.ui.graphics.vector.rememberVectorPainter
67+
import androidx.compose.ui.platform.ComposeView
68+
import androidx.compose.ui.platform.LocalContext
69+
import androidx.compose.ui.semantics.CustomAccessibilityAction
70+
import androidx.compose.ui.semantics.contentDescription
71+
import androidx.compose.ui.semantics.customActions
72+
import androidx.compose.ui.semantics.isTraversalGroup
73+
import androidx.compose.ui.semantics.semantics
74+
import androidx.compose.ui.semantics.stateDescription
75+
import androidx.compose.ui.semantics.traversalIndex
76+
import androidx.coordinatorlayout.widget.CoordinatorLayout
77+
import com.google.android.material.behavior.HideViewOnScrollBehavior
78+
import com.google.android.material.color.DynamicColors
79+
import com.google.android.material.resources.MaterialAttributes
80+
import com.google.android.material.snackbar.Snackbar
81+
import io.material.catalog.feature.DemoFragment
82+
83+
/** A fragment that displays the FAB Menu demo for the Catalog app. */
84+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
85+
class FabMenuDemoFragment : DemoFragment() {
86+
87+
private val itemCount = 50
88+
89+
override fun getDemoTitleResId(): Int {
90+
return R.string.cat_fab_menu_demo_title
91+
}
92+
93+
@SuppressLint("SetTextI18n")
94+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
95+
override fun onCreateDemoView(
96+
layoutInflater: LayoutInflater,
97+
viewGroup: ViewGroup?,
98+
bundle: Bundle?,
99+
): View {
100+
val context = requireContext()
101+
val view =
102+
layoutInflater.inflate(R.layout.cat_fab_menu_fragment, viewGroup, false /* attachToRoot */)
103+
104+
val mainList = view.findViewById<LinearLayout>(R.id.cat_fab_menu_main_list)
105+
val itemPadding = resources.getDimensionPixelOffset(R.dimen.cat_fab_menu_main_list_item_padding)
106+
107+
for (i in 1..itemCount) {
108+
val typedValue =
109+
MaterialAttributes.resolveTypedValueOrThrow(view, android.R.attr.selectableItemBackground)
110+
val selectableItemBackground = context.getDrawable(typedValue.resourceId)
111+
112+
val textView = TextView(context)
113+
textView.text = "Item $i"
114+
textView.isClickable = true
115+
textView.background = selectableItemBackground
116+
textView.setPadding(itemPadding, itemPadding, itemPadding, itemPadding)
117+
mainList.addView(textView)
118+
}
119+
120+
val dynamicColorOn =
121+
DynamicColors.isDynamicColorAvailable() &&
122+
MaterialAttributes.resolveBoolean(context, com.google.android.material.R.attr.isMaterial3DynamicColorApplied, false)
123+
124+
val composeView = view.findViewById<ComposeView>(R.id.cat_fab_menu_compose_view)
125+
composeView.setContent {
126+
val localContext = LocalContext.current
127+
val darkTheme = isSystemInDarkTheme()
128+
val colorsScheme =
129+
remember(localContext, darkTheme) {
130+
when {
131+
dynamicColorOn && darkTheme -> dynamicDarkColorScheme(localContext)
132+
dynamicColorOn && !darkTheme -> dynamicLightColorScheme(localContext)
133+
darkTheme -> darkColorScheme()
134+
else -> expressiveLightColorScheme()
135+
}
136+
}
137+
138+
MaterialExpressiveTheme(colorScheme = colorsScheme) {
139+
FabMenuDemoContent(
140+
onItemClick = { itemText ->
141+
// Do something in Views based on the result of the Compose FAB Menu item click.
142+
Snackbar.make(view, "\"$itemText\" item clicked!", Snackbar.LENGTH_LONG).show()
143+
},
144+
onExpandedChange = { expanded ->
145+
val layoutParams = composeView.layoutParams as CoordinatorLayout.LayoutParams
146+
layoutParams.behavior =
147+
if (expanded) null
148+
else HideViewOnScrollBehavior<View>(HideViewOnScrollBehavior.EDGE_BOTTOM)
149+
},
150+
)
151+
}
152+
}
153+
154+
return view
155+
}
156+
}
157+
158+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
159+
@Composable
160+
fun FabMenuDemoContent(onItemClick: (String) -> Unit, onExpandedChange: (Boolean) -> Unit) {
161+
val listState = rememberLazyListState()
162+
val fabVisible by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
163+
164+
Box {
165+
val items = remember {
166+
listOf(
167+
Icons.AutoMirrored.Filled.Message to "Reply",
168+
Icons.Filled.People to "Reply all",
169+
Icons.Filled.Contacts to "Forward",
170+
Icons.Filled.Snooze to "Snooze",
171+
Icons.Filled.Archive to "Archive",
172+
Icons.AutoMirrored.Filled.Label to "Label",
173+
)
174+
}
175+
176+
var fabMenuExpanded by rememberSaveable { mutableStateOf(false) }
177+
178+
LaunchedEffect(fabMenuExpanded) { onExpandedChange(fabMenuExpanded) }
179+
180+
BackHandler(fabMenuExpanded) { fabMenuExpanded = false }
181+
182+
FloatingActionButtonMenu(
183+
modifier =
184+
Modifier.align(Alignment.BottomEnd).windowInsetsPadding(WindowInsets.navigationBars),
185+
expanded = fabMenuExpanded,
186+
button = {
187+
ToggleFloatingActionButton(
188+
modifier =
189+
Modifier.semantics {
190+
traversalIndex = -1f
191+
stateDescription = if (fabMenuExpanded) "Expanded" else "Collapsed"
192+
contentDescription = "Toggle menu"
193+
}
194+
.animateFloatingActionButton(
195+
visible = fabVisible || fabMenuExpanded,
196+
alignment = Alignment.BottomEnd,
197+
),
198+
checked = fabMenuExpanded,
199+
onCheckedChange = { fabMenuExpanded = !fabMenuExpanded },
200+
) {
201+
val imageVector by remember {
202+
derivedStateOf { if (checkedProgress > 0.5f) Icons.Filled.Close else Icons.Filled.Add }
203+
}
204+
Icon(
205+
painter = rememberVectorPainter(imageVector),
206+
contentDescription = null,
207+
modifier = Modifier.animateIcon({ checkedProgress }),
208+
)
209+
}
210+
},
211+
) {
212+
items.forEachIndexed { i, item ->
213+
FloatingActionButtonMenuItem(
214+
modifier =
215+
Modifier.semantics {
216+
isTraversalGroup = true
217+
// Add a custom a11y action to allow closing the menu when focusing
218+
// the last menu item, since the close button comes before the first
219+
// menu item in the traversal order.
220+
if (i == items.size - 1) {
221+
customActions =
222+
listOf(
223+
CustomAccessibilityAction(
224+
label = "Close menu",
225+
action = {
226+
fabMenuExpanded = false
227+
true
228+
},
229+
)
230+
)
231+
}
232+
},
233+
onClick = {
234+
fabMenuExpanded = false
235+
onItemClick(item.second)
236+
},
237+
icon = { Icon(item.first, contentDescription = null) },
238+
text = { Text(text = item.second) },
239+
)
240+
}
241+
}
242+
}
243+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
Copyright 2025 The Android Open Source Project
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
https://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
-->
17+
18+
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
19+
android:layout_width="match_parent"
20+
android:layout_height="match_parent">
21+
22+
<androidx.core.widget.NestedScrollView
23+
android:layout_width="match_parent"
24+
android:layout_height="match_parent">
25+
26+
<LinearLayout
27+
android:id="@+id/cat_fab_menu_main_list"
28+
android:layout_width="match_parent"
29+
android:layout_height="wrap_content"
30+
android:orientation="vertical"/>
31+
</androidx.core.widget.NestedScrollView>
32+
33+
<androidx.compose.ui.platform.ComposeView
34+
android:id="@+id/cat_fab_menu_compose_view"
35+
android:layout_width="wrap_content"
36+
android:layout_height="wrap_content"
37+
android:layout_gravity="bottom|end" />
38+
39+
</androidx.coordinatorlayout.widget.CoordinatorLayout>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
Copyright 2025 The Android Open Source Project
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
https://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
-->
17+
18+
<resources>
19+
20+
<dimen name="cat_fab_menu_main_list_item_padding">16dp</dimen>
21+
</resources>

catalog/java/io/material/catalog/fab/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<string name="cat_fab_description" translatable="false">The Floating Action Button (FAB) represents your product\'s primary action, and should be considered a direct extension of its brand.</string>
2222
<string name="cat_extended_fab_demo_title" translatable="false">Extended FAB</string>
2323
<string name="cat_extended_fab_behavior_demo_title" translatable="false">Extended FAB Behavior</string>
24+
<string name="cat_fab_menu_demo_title" translatable="false">FAB Menu</string>
2425

2526
<string name="extended_fab_label" translatable="false">Extended FAB</string>
2627
<string name="cat_extended_fab_content_desc" translatable="false">Extended FAB</string>

0 commit comments

Comments
 (0)