๐ŸŽฏ Floating Tab Menu ๊ตฌํ˜„ ์„ธ๋ฏธ๋‚˜

Android์—์„œ BottomAppBar + FloatingActionButton์„ ํ™œ์šฉํ•˜์—ฌ ๋‹๋ณด์ด๋Š” ์ค‘์•™ ํƒญ ๋ฉ”๋‰ด๋ฅผ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ๋ฒ•


๐Ÿ“‹ ๋ชฉ์ฐจ

  1. ๊ฐœ์š”
  2. ์ „์ฒด ์•„ํ‚คํ…์ฒ˜
  3. ํ•ต์‹ฌ ์ฝ”๋“œ ๋ถ„์„
  1. ํ•ต์‹ฌ ๊ธฐ์ˆ  ํฌ์ธํŠธ
  2. ์‹ค์Šต ๊ณผ์ œ

๊ฐœ์š”

์ด ์„ธ๋ฏธ๋‚˜์—์„œ๋Š” ์ผ๋ฐ˜ ํƒญ ๋ฉ”๋‰ด์™€ ์กฐํ™”๋ฅผ ์ด๋ฃจ๋ฉด์„œ๋„ ํŠน๋ณ„ํžˆ ๋‹๋ณด์ด๋Š” ์ค‘์•™ ํ”Œ๋กœํŒ… ํƒญ์„ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ํ•™์Šตํ•ฉ๋‹ˆ๋‹ค.

๐ŸŽจ ๊ตฌํ˜„ ๊ฒฐ๊ณผ๋ฌผ

  • 4๊ฐœ์˜ ์ผ๋ฐ˜ ํƒญ (๋‰ด์Šค, ํ™ˆ, ๊ธฐ๋ก, ์„ค์ •)
  • 1๊ฐœ์˜ ํ”Œ๋กœํŒ… ์ค‘์•™ ํƒญ (๋งˆ์ดํฌ – ๋„ค์˜จ ๊ทธ๋ฆฐ/๋ธ”๋ฃจ ์ƒ‰์ƒ)
  • BottomAppBar์˜ Cradle(์˜ค๋ชฉํ•œ ํ™ˆ) ํšจ๊ณผ

์ „์ฒด ์•„ํ‚คํ…์ฒ˜

graph TB
    subgraph Layout["activity_main.xml"]
        A[CoordinatorLayout] --> B[BottomAppBar]
        A --> C[FloatingActionButton]
        B --> D[LinearLayout - ํƒญ ์ปจํ…Œ์ด๋„ˆ]
        D --> E[๋‰ด์Šค ํƒญ]
        D --> F[ํ™ˆ ํƒญ]
        D --> G["๋นˆ ๊ณต๊ฐ„ (FAB ์ž๋ฆฌ)"]
        D --> H[๊ธฐ๋ก ํƒญ]
        D --> I[์„ค์ • ํƒญ]
    end

    subgraph Kotlin["MainActivity.kt"]
        J[setupNavTabs] --> K[ํƒญ ํด๋ฆญ ๋ฆฌ์Šค๋„ˆ ์„ค์ •]
        L[setupFabMic] --> M[FAB ์ƒ‰์ƒ ๋ฐ ํด๋ฆญ ์„ค์ •]
        N[updateTabSelection] --> O[์„ ํƒ ์ƒํƒœ UI ์—…๋ฐ์ดํŠธ]
    end

    C -.->|layout_anchor| B

ํ•ต์‹ฌ ์ฝ”๋“œ ๋ถ„์„

1. ๋ ˆ์ด์•„์›ƒ ๊ตฌ์กฐ (activity_main.xml)

๐Ÿ”— BottomAppBar + FAB Cradle ํšจ๊ณผ

<!-- Bottom App Bar with cradle for FAB -->
<com.google.android.material.bottomappbar.BottomAppBar
    android:id="@+id/bottomAppBar"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom"
    android:backgroundTint="?attr/colorSurface"
    app:fabAlignmentMode="center"
    app:fabAnimationMode="slide"
    app:fabCradleMargin="8dp"
    app:fabCradleRoundedCornerRadius="16dp"
    app:fabCradleVerticalOffset="4dp"
    app:hideOnScroll="false"
    app:elevation="8dp">

[!IMPORTANT]
Cradle ํšจ๊ณผ ํ•ต์‹ฌ ์†์„ฑ๋“ค

  • fabAlignmentMode="center" – FAB๋ฅผ ์ค‘์•™์— ๋ฐฐ์น˜
  • fabCradleMargin="8dp" – FAB์™€ ๋ฐ” ์‚ฌ์ด ๊ฐ„๊ฒฉ
  • fabCradleRoundedCornerRadius="16dp" – ์˜ค๋ชฉํ•œ ๊ณก์„  ๋ฐ˜๊ฒฝ
  • fabCradleVerticalOffset="4dp" – FAB ์ˆ˜์ง ๋Œ์ถœ ์ •๋„

๐Ÿ”— ํƒญ ์ปจํ…Œ์ด๋„ˆ ๊ตฌ์กฐ (weightSum ํ™œ์šฉ)

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="56dp"
    android:orientation="horizontal"
    android:gravity="center_vertical"
    android:weightSum="4.8">

    <!-- ๋‰ด์Šค ํƒญ - weight 1 -->
    <LinearLayout
        android:id="@+id/navNews"
        android:layout_width="0dp"
        android:layout_weight="1"
        ... >
    </LinearLayout>

    <!-- ํ™ˆ ํƒญ - weight 1 -->
    <LinearLayout android:id="@+id/navHome" android:layout_weight="1" ... />

    <!-- FAB ๊ณต๊ฐ„ - weight 0.8 (์ž‘๊ฒŒ ์„ค์ •) -->
    <View
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="0.8" />

    <!-- ๊ธฐ๋ก ํƒญ - weight 1 -->
    <LinearLayout android:id="@+id/navHistory" android:layout_weight="1" ... />

    <!-- ์„ค์ • ํƒญ - weight 1 -->
    <LinearLayout android:id="@+id/navSettings" android:layout_weight="1" ... />

</LinearLayout>

[!TIP]
weightSum = 4.8 ๊ณ„์‚ฐ

  • ์ผ๋ฐ˜ ํƒญ 4๊ฐœ ร— 1 = 4
  • FAB ๊ณต๊ฐ„ = 0.8
  • ์ดํ•ฉ = 4.8

๐Ÿ”— FloatingActionButton ์„ค์ •

<!-- Floating Action Button for MIC (center, elevated) -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
    android:id="@+id/fabMic"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:src="@drawable/ic_mic_neon_tab"
    android:contentDescription="์Œ์„ฑ ์ž…๋ ฅ"
    app:layout_anchor="@id/bottomAppBar"
    app:tint="@null"
    app:backgroundTint="#00FF88"
    app:fabSize="normal"
    app:elevation="12dp"
    app:borderWidth="0dp" />

[!IMPORTANT]
FAB ํ•ต์‹ฌ ์†์„ฑ

  • app:layout_anchor="@id/bottomAppBar" – BottomAppBar์— ๊ณ ์ •
  • app:tint="@null" – ์•„์ด์ฝ˜ ์›๋ณธ ์ƒ‰์ƒ ์œ ์ง€ (๋„ค์˜จ ํšจ๊ณผ)
  • app:elevation="12dp" – ๋†’์€ elevation์œผ๋กœ ๋‹๋ณด์ด๊ฒŒ ํ•จ

2. ๋„ค์˜จ ์•„์ด์ฝ˜ ๋””์ž์ธ

๐Ÿ“ res/drawable/ic_mic_neon_tab.xml

<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="24dp"
    android:height="24dp"
    android:viewportWidth="24"
    android:viewportHeight="24">

    <!-- Microphone body with neon gradient effect -->
    <path
        android:fillColor="#00FF88"
        android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3z"/>

    <!-- Microphone stand with neon blue -->
    <path
        android:fillColor="#00BFFF"
        android:pathData="M17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z"/>

</vector>

[!TIP]
๋„ค์˜จ ์ปฌ๋Ÿฌ ์กฐํ•ฉ

  • ๋งˆ์ดํฌ ๋ณธ์ฒด: #00FF88 (๋„ค์˜จ ๊ทธ๋ฆฐ)
  • ๋งˆ์ดํฌ ์Šคํƒ ๋“œ: #00BFFF (๋„ค์˜จ ๋ธ”๋ฃจ)
  • ์ด ์กฐํ•ฉ์œผ๋กœ ๋ˆˆ์— ๋„๋ฉด์„œ๋„ ์„ธ๋ จ๋œ ๋А๋‚Œ ์—ฐ์ถœ

3. Kotlin ๋กœ์ง (MainActivity.kt)

๐Ÿ”— NavTab ๋ฐ์ดํ„ฐ ํด๋ž˜์Šค

// Navigation tabs
private data class NavTab(
    val container: LinearLayout,
    val icon: ImageView,
    val label: TextView,
    var destinationId: Int
)
private lateinit var navTabs: List<NavTab>
private var currentDestinationId: Int = R.id.navigation_home

// Neon colors for MIC FAB
private val neonGreen = Color.parseColor("#00FF88")
private val neonBlue = Color.parseColor("#00BFFF")

๐Ÿ”— ํƒญ ์ดˆ๊ธฐํ™” ํ•จ์ˆ˜

private fun setupNavTabs() {
    val homeDestinationId = if (isRealTimeMode) 
        R.id.navigation_dashboard else R.id.navigation_home

    navTabs = listOf(
        NavTab(binding.navNews, binding.iconNews, 
               binding.labelNews, R.id.navigation_news),
        NavTab(binding.navHome, binding.iconHome, 
               binding.labelHome, homeDestinationId),
        NavTab(binding.navHistory, binding.iconHistory, 
               binding.labelHistory, R.id.navigation_history),
        NavTab(binding.navSettings, binding.iconSettings, 
               binding.labelSettings, R.id.navigation_notifications)
    )

    // ๊ฐ ํƒญ์— ํด๋ฆญ ๋ฆฌ์Šค๋„ˆ ์„ค์ •
    navTabs.forEach { tab ->
        tab.container.setOnClickListener {
            navigateTo(tab.destinationId)
        }
    }
}

๐Ÿ”— FAB ์„ค์ • ํ•จ์ˆ˜

private fun setupFabMic() {
    binding.fabMic.apply {
        backgroundTintList = ColorStateList.valueOf(neonGreen)
        imageTintList = ColorStateList.valueOf(Color.WHITE)

        setOnClickListener {
            navigateTo(R.id.navigation_dashboard)
        }
    }
}

๐Ÿ”— ์„ ํƒ ์ƒํƒœ ์—…๋ฐ์ดํŠธ (ํ•ต์‹ฌ ๋กœ์ง)

private fun updateTabSelection(selectedId: Int) {
    currentDestinationId = selectedId

    // ํ…Œ๋งˆ์—์„œ ์ƒ‰์ƒ ๊ฐ€์ ธ์˜ค๊ธฐ
    val typedValue = android.util.TypedValue()
    theme.resolveAttribute(android.R.attr.colorPrimary, typedValue, true)
    val primaryColor = typedValue.data

    theme.resolveAttribute(
        com.google.android.material.R.attr.colorOnSurface, 
        typedValue, true)
    val onSurfaceColor = typedValue.data
    val grayColor = Color.argb(153, 
        Color.red(onSurfaceColor), 
        Color.green(onSurfaceColor), 
        Color.blue(onSurfaceColor))

    // ์ผ๋ฐ˜ ํƒญ ์ƒํƒœ ์—…๋ฐ์ดํŠธ
    navTabs.forEach { tab ->
        val isSelected = tab.destinationId == selectedId
        val color = if (isSelected) primaryColor else grayColor
        tab.icon.imageTintList = ColorStateList.valueOf(color)
        tab.label.setTextColor(color)
    }

    // FAB ์ƒํƒœ ์—…๋ฐ์ดํŠธ (์„ ํƒ ์‹œ ํ™•๋Œ€ ํšจ๊ณผ)
    val isMicSelected = selectedId == R.id.navigation_dashboard
    binding.fabMic.apply {
        if (isMicSelected) {
            backgroundTintList = ColorStateList.valueOf(neonGreen)
            scaleX = 1.1f  // 10% ํ™•๋Œ€
            scaleY = 1.1f
        } else {
            backgroundTintList = ColorStateList.valueOf(
                Color.parseColor("#00CC6A"))  // ์•ฝ๊ฐ„ ์–ด๋‘์šด ํ†ค
            scaleX = 1.0f
            scaleY = 1.0f
        }
    }
}

[!NOTE]
์„ ํƒ ์‹œ FAB ํšจ๊ณผ

  • ์„ ํƒ ์‹œ: ๋ฐ์€ ๋„ค์˜จ ๊ทธ๋ฆฐ #00FF88 + 1.1๋ฐฐ ํ™•๋Œ€
  • ๋น„์„ ํƒ ์‹œ: ์•ฝ๊ฐ„ ์–ด๋‘์šด #00CC6A + ์›๋ž˜ ํฌ๊ธฐ

4. ์ƒ‰์ƒ ๋ฐ ์Šคํƒ€์ผ๋ง

๐Ÿ“ res/values/colors.xml (๋ผ์ดํŠธ ๋ชจ๋“œ)

<!-- Bottom Nav Unselected Color -->
<color name="bottom_nav_icon_unselected">#43474E</color>

๐Ÿ“ res/values-night/colors.xml (๋‹คํฌ ๋ชจ๋“œ)

<!-- Bottom Nav Unselected Color for Dark Theme -->
<color name="bottom_nav_icon_unselected">#C3C7CF</color>

๐Ÿ“ res/color/selector_bottom_nav_icon.xml

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- Selected state -->
    <item android:color="?attr/colorPrimary" android:state_checked="true" />
    <!-- Unselected state (default) -->
    <item android:color="@color/bottom_nav_icon_unselected" />
</selector>

ํ•ต์‹ฌ ๊ธฐ์ˆ  ํฌ์ธํŠธ

1๏ธโƒฃ CoordinatorLayout + BottomAppBar ์กฐํ•ฉ

๊ตฌ์„ฑ์š”์†Œ์—ญํ• 
CoordinatorLayoutFAB์™€ BottomAppBar ๊ฐ„์˜ ์ƒํ˜ธ์ž‘์šฉ ์กฐ์œจ
BottomAppBarCradle(์˜ค๋ชฉํ•œ ํ™ˆ) ํšจ๊ณผ ์ œ๊ณต
FloatingActionButton์ค‘์•™์— ๋‹๋ณด์ด๋Š” ๋ฒ„ํŠผ

2๏ธโƒฃ weight ์‹œ์Šคํ…œ์„ ํ™œ์šฉํ•œ ๊ท ๋“ฑ ๋ฐฐ์น˜

weightSum = 4.8
โ”œโ”€โ”€ ๋‰ด์Šค (1.0) โ”€โ”ฌโ”€ ํ™ˆ (1.0) โ”€โ”ฌโ”€ FAB๊ณต๊ฐ„ (0.8) โ”€โ”ฌโ”€ ๊ธฐ๋ก (1.0) โ”€โ”ฌโ”€ ์„ค์ • (1.0)
                โ”‚            โ”‚                 โ”‚             โ”‚
              20.8%        20.8%              16.7%        20.8%        20.8%

3๏ธโƒฃ ํ…Œ๋งˆ ์ ์‘ํ˜• ์ƒ‰์ƒ ์ฒ˜๋ฆฌ

// ๋Ÿฐํƒ€์ž„์— ํ…Œ๋งˆ ์ƒ‰์ƒ ๊ฐ€์ ธ์˜ค๊ธฐ
theme.resolveAttribute(android.R.attr.colorPrimary, typedValue, true)
val primaryColor = typedValue.data

4๏ธโƒฃ ์„ ํƒ ์ƒํƒœ ์‹œ๊ฐ์  ํ”ผ๋“œ๋ฐฑ

์ƒํƒœFAB ์ƒ‰์ƒFAB ํฌ๊ธฐ
์„ ํƒ๋จ#00FF88 (๋ฐ์€ ๋„ค์˜จ ๊ทธ๋ฆฐ)1.1x
๋น„์„ ํƒ#00CC6A (์–ด๋‘์šด ๊ทธ๋ฆฐ)1.0x

์‹ค์Šต ๊ณผ์ œ

๊ณผ์ œ 1: ๊ธฐ๋ณธ ๊ตฌํ˜„

BottomAppBar + FAB ์กฐํ•ฉ์œผ๋กœ 5๊ฐœ ํƒญ ๋„ค๋น„๊ฒŒ์ด์…˜ ๊ตฌํ˜„ํ•˜๊ธฐ

๊ณผ์ œ 2: ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ถ”๊ฐ€

FAB ์„ ํƒ ์‹œ ObjectAnimator๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ถ€๋“œ๋Ÿฌ์šด ํ™•๋Œ€/์ถ•์†Œ ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ ์šฉ

๊ณผ์ œ 3: ๋„ค์˜จ ํšจ๊ณผ ํ™•์žฅ

  • ์„ ํƒ ์‹œ FAB์— ๊ทธ๋ฆผ์ž ์ƒ‰์ƒ ๋ณ€๊ฒฝ (app:elevation + ์ปค์Šคํ…€ ๊ทธ๋ฆผ์ž)
  • ๋˜๋Š” ์™ธ๊ณฝ์„ (stroke) ๋„ค์˜จ ํšจ๊ณผ ์ถ”๊ฐ€

๐Ÿ“ ๊ด€๋ จ ํŒŒ์ผ ๋ชฉ๋ก

ํŒŒ์ผ ๊ฒฝ๋กœ์„ค๋ช…
activity_main.xml๋ฉ”์ธ ๋ ˆ์ด์•„์›ƒ (BottomAppBar + FAB)
MainActivity.ktํƒญ ์ปจํŠธ๋กค ๋กœ์ง
ic_mic_neon_tab.xml๋„ค์˜จ ๋งˆ์ดํฌ ์•„์ด์ฝ˜
selector_bottom_nav_icon.xmlํƒญ ์•„์ด์ฝ˜ ์ƒ‰์ƒ ์…€๋ ‰ํ„ฐ
colors.xml์ƒ‰์ƒ ์ •์˜
colors.xml (night)๋‹คํฌ๋ชจ๋“œ ์ƒ‰์ƒ

์ฝ”๋ฉ˜ํŠธ

๋‹ต๊ธ€ ๋‚จ๊ธฐ๊ธฐ

์ด๋ฉ”์ผ ์ฃผ์†Œ๋Š” ๊ณต๊ฐœ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ•„์ˆ˜ ํ•„๋“œ๋Š” *๋กœ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค