Skip to content

Commit 8839aee

Browse files
authored
[FEAT/#735] Add native ad to bottom navigation (#738)
1 parent 65e0e54 commit 8839aee

File tree

8 files changed

+338
-40
lines changed

8 files changed

+338
-40
lines changed

build-logic/convention/src/main/kotlin/com/hilingual/buildlogic/BuildType.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ fun Project.configureBuildTypes(
4444
"ADMOB_INLINEBANNER_UNIT_ID",
4545
properties.getQuotedProperty("admob.inlinebanner.$prefix.id")
4646
)
47+
buildConfigField(
48+
"String",
49+
"ADMOB_NATIVE_UNIT_ID",
50+
properties.getQuotedProperty("admob.native.$prefix.id")
51+
)
4752
}
4853

4954
commonExtension.apply {

core/ads/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ android {
1212

1313
dependencies {
1414
implementation(projects.core.common)
15+
implementation(projects.core.designsystem)
1516
implementation(libs.gma.ads)
1617

1718
// Workaround for GMA Next Gen SDK beta03 Cronet namespace bug
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.hilingual.core.ads.native
2+
3+
import androidx.compose.foundation.layout.fillMaxWidth
4+
import androidx.compose.runtime.Composable
5+
import androidx.compose.ui.Modifier
6+
import androidx.compose.ui.platform.LocalInspectionMode
7+
import androidx.compose.ui.tooling.preview.Preview
8+
import androidx.compose.ui.viewinterop.AndroidView
9+
import com.hilingual.core.ads.native.component.NativeLineAdContent
10+
import com.hilingual.core.designsystem.theme.HilingualTheme
11+
12+
@Composable
13+
fun HilingualNativeLineAd(
14+
adUnitId: String,
15+
modifier: Modifier = Modifier,
16+
) {
17+
val isPreviewMode = LocalInspectionMode.current
18+
19+
if (isPreviewMode) {
20+
NativeLineAdContent(
21+
title = "광고 이름",
22+
body = "메인 카피",
23+
modifier = modifier,
24+
)
25+
} else {
26+
val nativeAd = rememberNativeAd(adUnitId)
27+
if (nativeAd != null) {
28+
AndroidView(
29+
modifier = modifier.fillMaxWidth(),
30+
factory = { context ->
31+
createNativeAdView(context, nativeAd)
32+
},
33+
)
34+
}
35+
}
36+
}
37+
38+
@Preview
39+
@Composable
40+
private fun HilingualNativeLineAdPreview() {
41+
HilingualTheme {
42+
HilingualNativeLineAd("")
43+
}
44+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.hilingual.core.ads.native
2+
3+
import android.content.Context
4+
import androidx.compose.runtime.Composable
5+
import androidx.compose.runtime.DisposableEffect
6+
import androidx.compose.runtime.getValue
7+
import androidx.compose.runtime.mutableStateOf
8+
import androidx.compose.runtime.remember
9+
import androidx.compose.runtime.setValue
10+
import androidx.compose.ui.platform.ComposeView
11+
import com.google.android.libraries.ads.mobile.sdk.common.LoadAdError
12+
import com.google.android.libraries.ads.mobile.sdk.nativead.NativeAd
13+
import com.google.android.libraries.ads.mobile.sdk.nativead.NativeAdLoader
14+
import com.google.android.libraries.ads.mobile.sdk.nativead.NativeAdLoaderCallback
15+
import com.google.android.libraries.ads.mobile.sdk.nativead.NativeAdRequest
16+
import com.google.android.libraries.ads.mobile.sdk.nativead.NativeAdView
17+
import com.hilingual.core.ads.native.component.NativeLineAdContent
18+
import timber.log.Timber
19+
20+
@Composable
21+
internal fun rememberNativeAd(adUnitId: String): NativeAd? {
22+
var loadedAdState by remember { mutableStateOf<NativeAd?>(null) }
23+
24+
DisposableEffect(adUnitId) {
25+
var isDisposed = false
26+
27+
val adRequest = NativeAdRequest.Builder(
28+
adUnitId = adUnitId,
29+
nativeAdTypes = listOf(NativeAd.NativeAdType.NATIVE),
30+
).build()
31+
32+
val adCallback = object : NativeAdLoaderCallback {
33+
override fun onNativeAdLoaded(nativeAd: NativeAd) {
34+
if (isDisposed) nativeAd.destroy() else loadedAdState = nativeAd
35+
}
36+
37+
override fun onAdFailedToLoad(adError: LoadAdError) {
38+
Timber.tag("GMA").e("GMA Next Gen 네이티브 광고 로드 실패: %s", adError)
39+
}
40+
}
41+
42+
NativeAdLoader.load(adRequest, adCallback)
43+
44+
onDispose {
45+
isDisposed = true
46+
loadedAdState?.destroy()
47+
loadedAdState = null
48+
}
49+
}
50+
51+
return loadedAdState
52+
}
53+
54+
internal fun createNativeAdView(
55+
context: Context,
56+
nativeAd: NativeAd,
57+
): NativeAdView {
58+
val composeView = ComposeView(context).apply {
59+
setContent {
60+
NativeLineAdContent(
61+
title = nativeAd.headline ?: "",
62+
body = nativeAd.callToAction ?: nativeAd.body ?: "",
63+
)
64+
}
65+
}
66+
67+
return NativeAdView(context).apply {
68+
addView(composeView)
69+
headlineView = composeView
70+
callToActionView = composeView
71+
registerNativeAd(nativeAd, null)
72+
}
73+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.hilingual.core.ads.native.component
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.heightIn
6+
import androidx.compose.foundation.layout.padding
7+
import androidx.compose.foundation.layout.widthIn
8+
import androidx.compose.foundation.shape.RoundedCornerShape
9+
import androidx.compose.material3.Text
10+
import androidx.compose.runtime.Composable
11+
import androidx.compose.ui.Alignment
12+
import androidx.compose.ui.Modifier
13+
import androidx.compose.ui.draw.clip
14+
import androidx.compose.ui.tooling.preview.Preview
15+
import androidx.compose.ui.unit.dp
16+
import com.hilingual.core.designsystem.theme.HilingualTheme
17+
18+
@Composable
19+
fun AdMark(
20+
modifier: Modifier = Modifier,
21+
) {
22+
Box(
23+
modifier = modifier
24+
.clip(RoundedCornerShape(10.dp))
25+
.background(HilingualTheme.colors.gray400)
26+
.padding(vertical = 1.dp, horizontal = 3.dp)
27+
.widthIn(min = 24.dp)
28+
.heightIn(min = 16.dp),
29+
contentAlignment = Alignment.Center,
30+
) {
31+
Text(
32+
text = "AD",
33+
color = HilingualTheme.colors.white,
34+
style = HilingualTheme.typography.bodyM12,
35+
)
36+
}
37+
}
38+
39+
@Preview
40+
@Composable
41+
private fun AdMarkPreview() {
42+
HilingualTheme {
43+
AdMark()
44+
}
45+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package com.hilingual.core.ads.native.component
2+
3+
import androidx.compose.animation.AnimatedContent
4+
import androidx.compose.animation.fadeIn
5+
import androidx.compose.animation.fadeOut
6+
import androidx.compose.animation.slideInVertically
7+
import androidx.compose.animation.slideOutVertically
8+
import androidx.compose.animation.togetherWith
9+
import androidx.compose.foundation.background
10+
import androidx.compose.foundation.layout.Arrangement
11+
import androidx.compose.foundation.layout.Row
12+
import androidx.compose.foundation.layout.fillMaxWidth
13+
import androidx.compose.foundation.layout.heightIn
14+
import androidx.compose.foundation.layout.padding
15+
import androidx.compose.material3.Text
16+
import androidx.compose.runtime.Composable
17+
import androidx.compose.runtime.LaunchedEffect
18+
import androidx.compose.runtime.getValue
19+
import androidx.compose.runtime.mutableIntStateOf
20+
import androidx.compose.runtime.mutableStateOf
21+
import androidx.compose.runtime.remember
22+
import androidx.compose.runtime.setValue
23+
import androidx.compose.ui.Alignment
24+
import androidx.compose.ui.Modifier
25+
import androidx.compose.ui.text.TextLayoutResult
26+
import androidx.compose.ui.text.style.TextOverflow
27+
import androidx.compose.ui.tooling.preview.Preview
28+
import androidx.compose.ui.unit.dp
29+
import com.hilingual.core.designsystem.theme.HilingualTheme
30+
import kotlinx.coroutines.delay
31+
import kotlinx.coroutines.isActive
32+
33+
@Composable
34+
internal fun NativeLineAdContent(
35+
title: String,
36+
body: String,
37+
modifier: Modifier = Modifier,
38+
) {
39+
var chunks by remember(body) { mutableStateOf(listOf(body)) }
40+
var currentIndex by remember(body) { mutableIntStateOf(0) }
41+
42+
LaunchedEffect(body) {
43+
while (isActive) {
44+
delay(5000L)
45+
if (chunks.size > 1) {
46+
currentIndex = (currentIndex + 1) % chunks.size
47+
}
48+
}
49+
}
50+
51+
AnimatedContent(
52+
targetState = currentIndex,
53+
transitionSpec = {
54+
slideInVertically { it } + fadeIn() togetherWith
55+
slideOutVertically { -it } + fadeOut()
56+
},
57+
label = "NativeAdRolling",
58+
modifier = modifier
59+
.fillMaxWidth()
60+
.background(HilingualTheme.colors.white)
61+
.heightIn(min = 32.dp)
62+
.padding(horizontal = 16.dp, vertical = 4.dp),
63+
) { index ->
64+
Row(
65+
verticalAlignment = Alignment.CenterVertically,
66+
horizontalArrangement = Arrangement.spacedBy(6.dp),
67+
) {
68+
if (index == 0) {
69+
AdMark()
70+
Text(
71+
text = title,
72+
color = HilingualTheme.colors.gray850,
73+
style = HilingualTheme.typography.bodyM12,
74+
maxLines = 1,
75+
)
76+
}
77+
78+
Text(
79+
text = chunks[index],
80+
color = HilingualTheme.colors.gray850,
81+
style = HilingualTheme.typography.captionR12,
82+
maxLines = 1,
83+
overflow = TextOverflow.Ellipsis,
84+
onTextLayout = { result ->
85+
if (result.hasVisualOverflow && chunks.size == index + 1) {
86+
val nextChunk = result.getNextChunk(chunks[index])
87+
if (nextChunk.isNotEmpty()) chunks = chunks + nextChunk
88+
}
89+
},
90+
modifier = Modifier.weight(1f, fill = false),
91+
)
92+
}
93+
}
94+
}
95+
96+
private fun TextLayoutResult.getNextChunk(text: String): String {
97+
val end = getLineEnd(lineIndex = 0, visibleEnd = true)
98+
return if (end > 0) text.substring(end).trim() else ""
99+
}
100+
101+
@Preview
102+
@Composable
103+
private fun NativeLineAdContentPreview() {
104+
HilingualTheme {
105+
NativeLineAdContent(
106+
title = "광고 이름",
107+
body = "광고 내용입니다. 내용이 길면 5초 후에 롤링됩니다." +
108+
"아주 아주 아주 아주 아주 길어도 롤링됩니다." +
109+
"그리고 다시 롤링됩니다. 그래서 설명이 잘리지 않습니다.",
110+
)
111+
}
112+
}

presentation/main/src/main/java/com/hilingual/presentation/main/MainScreen.kt

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,16 @@ package com.hilingual.presentation.main
1717

1818
import androidx.activity.compose.BackHandler
1919
import androidx.activity.compose.LocalActivity
20+
import androidx.compose.animation.AnimatedVisibility
2021
import androidx.compose.animation.EnterTransition
2122
import androidx.compose.animation.ExitTransition
23+
import androidx.compose.animation.animateContentSize
24+
import androidx.compose.animation.fadeIn
25+
import androidx.compose.animation.fadeOut
26+
import androidx.compose.animation.slideIn
27+
import androidx.compose.animation.slideOut
2228
import androidx.compose.foundation.layout.Box
29+
import androidx.compose.foundation.layout.Column
2330
import androidx.compose.foundation.layout.fillMaxSize
2431
import androidx.compose.foundation.layout.navigationBarsPadding
2532
import androidx.compose.foundation.layout.padding
@@ -36,11 +43,13 @@ import androidx.compose.runtime.rememberCoroutineScope
3643
import androidx.compose.runtime.setValue
3744
import androidx.compose.ui.Alignment
3845
import androidx.compose.ui.Modifier
46+
import androidx.compose.ui.unit.IntOffset
3947
import androidx.compose.ui.unit.dp
4048
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
4149
import androidx.lifecycle.compose.collectAsStateWithLifecycle
4250
import androidx.navigation.compose.NavHost
4351
import androidx.navigation.navOptions
52+
import com.hilingual.core.ads.native.HilingualNativeLineAd
4453
import com.hilingual.core.common.analytics.Tracker
4554
import com.hilingual.core.common.app.AppRestarter
4655
import com.hilingual.core.common.model.HilingualMessage
@@ -128,9 +137,8 @@ internal fun MainScreen(
128137
) {
129138
Scaffold(
130139
bottomBar = {
131-
MainBottomBar(
132-
visible = isBottomBarVisible,
133-
tabs = MainTab.entries.toPersistentList(),
140+
BottomSection(
141+
isVisible = isBottomBarVisible,
134142
currentTab = currentTab,
135143
onTabSelected = appState::navigate,
136144
)
@@ -143,7 +151,6 @@ internal fun MainScreen(
143151
popExitTransition = { ExitTransition.None },
144152
navController = appState.navController,
145153
startDestination = appState.startDestination,
146-
147154
) {
148155
splashNavGraph(
149156
navigateToAuth = appState::navigateToAuth,
@@ -258,7 +265,7 @@ internal fun MainScreen(
258265
modifier = Modifier
259266
.fillMaxSize()
260267
.navigationBarsPadding()
261-
.padding(bottom = 82.dp),
268+
.padding(bottom = 106.dp),
262269
) {
263270
SnackbarHost(hostState = snackBarHostState) { data ->
264271
when (val visuals = data.visuals) {
@@ -301,3 +308,30 @@ private fun HandleBackPressToExit(
301308
backPressedTime = System.currentTimeMillis()
302309
}
303310
}
311+
312+
@Composable
313+
private fun BottomSection(
314+
isVisible: Boolean,
315+
currentTab: MainTab?,
316+
onTabSelected: (MainTab) -> Unit,
317+
modifier: Modifier = Modifier,
318+
) {
319+
AnimatedVisibility(
320+
visible = isVisible,
321+
enter = fadeIn() + slideIn { IntOffset(0, it.height) },
322+
exit = fadeOut() + slideOut { IntOffset(0, it.height) },
323+
) {
324+
Column(
325+
modifier = modifier
326+
.animateContentSize()
327+
.navigationBarsPadding(),
328+
) {
329+
MainBottomBar(
330+
tabs = MainTab.entries.toPersistentList(),
331+
currentTab = currentTab,
332+
onTabSelected = onTabSelected,
333+
)
334+
HilingualNativeLineAd(adUnitId = BuildConfig.ADMOB_NATIVE_UNIT_ID)
335+
}
336+
}
337+
}

0 commit comments

Comments
 (0)