Today's

길을 나서지 않으면 그 길에서 만날 수 있는 사람을 만날 수 없다

모바일 앱(안드로이드)

외국인관광객을 위한 다국어 환영 앱 첫 화면, Jetpack Compose로 구현하기

Billcorea 2025. 5. 26. 15:24
반응형

🌍 외국인을 위한 다국어 환영 앱 첫 화면, Jetpack Compose로 구현하기

인트로 화면

 
한국을 방문하는 외국인 여행자들이 보다 따뜻한 인사를 받을 수 있도록, 앱의 첫 화면에서 다양한 언어로 환영 인사를 전하는 기능을 Jetpack Compose로 구현해보았습니다.
이 포스트에서는 그 구현 과정을 공유합니다.


✅ 프로젝트 개요

  • 목표: 한국을 방문한 외국인에게 친근하게 다가가는 애니메이션 환영 화면 제공
  • 기술 스택: Jetpack Compose, Kotlin
  • 특징:
    • 다국어 환영 인사 애니메이션
    • 각 언어에 해당하는 국기 아이콘 표시
    • 배경으로 한국의 가을 이미지 사용
    • 선택된 언어를 앱의 언어 설정으로 반영

🖼 구현된 주요 화면

1. 환영 애니메이션 화면

  • 왼쪽 상단에서 등장하여 오른쪽 하단으로 이동
  • 도중에 회전하며 중앙으로 이동, 확대 애니메이션 포함
  • 언어:
    • 한국어, 영어, 중국어, 일본어, 베트남어, 대만어, 필리핀어, 태국어
  • 언어에 맞는 국기 아이콘 함께 표시
  • 문장 길이에 따라 자동 위치 보정

※ Jetpack Compose의 Animatable, graphicsLayer, LaunchedEffect 등을 활용

2. 다음 버튼

  • 하단 중앙 배치
  • 현재 표시 중인 언어로 버튼 텍스트 자동 변경
  • 클릭 시 Haptic Feedback 제공
  • 해당 언어를 앱의 기본 언어로 설정
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.os.LocaleList
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
import com.billcoreatech.opdgang1127.R
import kotlinx.coroutines.delay
import java.util.Locale
import androidx.core.content.edit

@SuppressLint("ConfigurationScreenWidthHeight")
@Composable
fun NewFaceMain(onNextClick: () -> Unit) {
    val messages = listOf(
        "환영합니다",               // 한국어
        "欢迎",                    // 중국어
        "ようこそ",               // 일본어
        "Welcome",                // 영어
        "Chào mừng",              // 베트남어
        "歡迎",                    // 대만어
        "Maligayang pagdating",   // 필리핀어
        "ยินดีต้อนรับ"   // 태국어
    )

    val flags = mapOf(
        "환영합니다" to R.drawable.flag_korea,
        "欢迎" to R.drawable.flag_china,
        "ようこそ" to R.drawable.flag_japan,
        "Welcome" to R.drawable.flag_usa,
        "Chào mừng" to R.drawable.flag_vietnam,
        "歡迎" to R.drawable.flag_taiwan,
        "Maligayang pagdating" to R.drawable.flag_philippines,
        "ยินดีต้อนรับ" to R.drawable.flag_thailand
    )

    val nextTexts = mapOf(
        "환영합니다" to "다음",
        "欢迎" to "下一步",
        "ようこそ" to "次へ",
        "Welcome" to "Next",
        "Chào mừng" to "Tiếp theo",
        "歡迎" to "下一步",
        "Maligayang pagdating" to "Susunod",
        "ยินดีต้อนรับ" to "ถัดไป"
    )

    val languageCodeMap = mapOf(
        "환영합니다" to "ko",
        "欢迎" to "zh",
        "ようこそ" to "ja",
        "Welcome" to "en",
        "Chào mừng" to "vi",
        "歡迎" to "zh-TW",
        "Maligayang pagdating" to "tl",
        "ยินดีต้อนรับ" to "th"
    )

    var currentIndex by remember { mutableStateOf(0) }
    val message = messages[currentIndex]
    val nextText = nextTexts[message] ?: "Next"
    val flagRes = flags[message] ?: R.drawable.flag_korea
    val haptic = LocalHapticFeedback.current

    val offsetX = remember { Animatable(0f) }
    val offsetY = remember { Animatable(0f) }
    val rotation = remember { Animatable(0f) }
    val scale = remember { Animatable(1f) }

    val configuration = LocalConfiguration.current
    val screenWidth = configuration.screenWidthDp.dp
    val screenHeight = configuration.screenHeightDp.dp
    val density = LocalDensity.current

    val messageOffsetAdjustment = mapOf(
        "환영합니다" to Pair(-150, -50),
        "欢迎" to Pair(-110, -50),
        "ようこそ" to Pair(-130, -50),
        "Welcome" to Pair(-140, -50),
        "Chào mừng" to Pair(-200, -50),
        "歡迎" to Pair(-110, -50),
        "Maligayang pagdating" to Pair(-250, -50),
        "ยินดีต้อนรับ" to Pair(-120, -50)
    )

    val context = LocalContext.current

    val onLanguageSelected = {
        val langCode = languageCodeMap[message] ?: "en"

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            val localeList = LocaleListCompat.forLanguageTags(langCode)
            AppCompatDelegate.setApplicationLocales(localeList)
        } else {
            val locale = Locale(langCode)
            Locale.setDefault(locale)
            val config = Configuration()
            config.setLocale(locale)
            context.resources.updateConfiguration(config, context.resources.displayMetrics)
        }

        context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE)
            .edit {
                putString("language", langCode)
            }
        // 언어 설정을 저장하고 다음 화면으로 이동
        onNextClick()
    }


    LaunchedEffect(message) {
        offsetX.snapTo(0f)
        offsetY.snapTo(0f)
        rotation.snapTo(0f)
        scale.snapTo(1f)

        val endX = with(density) { (screenWidth * 0.8f).toPx() }
        val endY = with(density) { (screenHeight * 0.3f).toPx() }

        val (adjustX, adjustY) = messageOffsetAdjustment[message] ?: Pair(-100, -50)
        val centerX = with(density) { (screenWidth / 2).toPx() + adjustX}
        val centerY = with(density) { (screenHeight / 2).toPx() + adjustY }

        // 곡선형 경로 이동
        val midX = (endX + centerX) / 2 + 100f
        val midY = (endY + centerY) / 2 - 100f

        offsetX.animateTo(endX, animationSpec = tween(800, easing = FastOutSlowInEasing))
        offsetY.animateTo(endY, animationSpec = tween(800, easing = LinearOutSlowInEasing))

        rotation.animateTo(360f, animationSpec = tween(600))

        offsetX.animateTo(midX, animationSpec = tween(400))
        offsetY.animateTo(midY, animationSpec = tween(400))

        offsetX.animateTo(centerX, animationSpec = tween(400))
        offsetY.animateTo(centerY, animationSpec = tween(400))

        scale.animateTo(
            targetValue = 1.5f,
            animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing)
        )
        delay(500)
        scale.animateTo(1f)

        delay(1000)
        currentIndex = (currentIndex + 1) % messages.size
    }

    Box(modifier = Modifier.fillMaxSize()) {
        Image(
            painter = painterResource(id = R.drawable.autumn_korea),
            contentDescription = "Autumn in Korea",
            contentScale = ContentScale.Crop,
            modifier = Modifier.fillMaxSize()
        )
        Image(
            painter = painterResource(id = R.drawable.ic_opdigang_v2),
            contentDescription = "App Logo",
            modifier = Modifier
                .padding(start = 16.dp, top = 48.dp)
                .size(64.dp)
        )
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(
                    brush = Brush.verticalGradient(
                        colors = listOf(Color.Transparent, Color(0x88000000)),
                        startY = 300f, endY = 1200f
                    )
                )
        ) {}

        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            modifier = Modifier
                .offset {
                    val bounceX = offsetX.value + kotlin.math.sin(offsetY.value / 60) * 8
                    val bounceY = offsetY.value
                    IntOffset(bounceX.toInt(), bounceY.toInt())
                }
                .graphicsLayer(
                    rotationZ = rotation.value,
                    scaleX = scale.value,
                    scaleY = scale.value
                )
        ) {
            Image(
                painter = painterResource(id = flagRes),
                contentDescription = "Flag",
                modifier = Modifier
                    .size(36.dp)
                    .padding(bottom = 4.dp)
            )
            Text(
                text = message,
                fontSize = if (message.length > 15) 24.sp else 32.sp,
                fontWeight = FontWeight.Bold,
                color = Color.White,
                textAlign = TextAlign.Center,
                maxLines = 2,
                overflow = TextOverflow.Ellipsis,
                modifier = Modifier.widthIn(max = 240.dp)
            )
        }

        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(bottom = 48.dp),
            verticalArrangement = Arrangement.Bottom,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Button(
                onClick = {
                    haptic.performHapticFeedback(HapticFeedbackType.LongPress)
                    onLanguageSelected()
                },
                shape = RoundedCornerShape(24.dp),
                colors = ButtonDefaults.buttonColors(containerColor = Color(0xAA000000)),
                contentPadding = PaddingValues(horizontal = 32.dp, vertical = 12.dp)
            ) {
                Text(nextText, fontSize = 18.sp, color = Color.White)
            }
        }
    }
}

🌐 다국어 및 로컬 설정

  • res/values-XX/strings.xml 파일을 각 언어별로 분리
  • AppCompatDelegate를 통해 앱 내 언어 설정 변경 지원
  • LocaleListCompat를 사용하여 호환성 확보

예시 - 태국어 strings.xml

<string name="intro_text">ทำให้การเดินทางของคุณง่ายขึ้น! ค้นหาข้อมูลท้องถิ่นตอนนี้เลย</string>
<string name="get_started">เริ่มต้น</string>

🇹🇭 태국어 추가 시 작업 내용

  • 문장: “ยินดีต้อนรับ” 추가
  • 국기: flag_thailand.png 리소스 추가 및 연결
  • 위치 보정값: Pair(-120, -50) 사용

🔚 다음 작업 예고

  • 인트로 이후 화면 디자인 및 기능 구현
  • 위치 기반 추천 정보 및 서비스 안내
  • 사용자 설정 및 프로필 저장 기능 연동

🧩 마무리하며

이번 작업을 통해 외국인 사용자에게 보다 문화적 배려를 담은 UX를 제공할 수 있는 첫 발걸음을 마련했습니다.
Jetpack Compose의 직관적인 구조와 애니메이션 지원 덕분에 자연스럽고 따뜻한 인사 화면을 구현할 수 있었습니다.
다음 포스트에서는 인트로 이후 화면을 구성해보겠습니다. 감사합니다!
 
 

움직이는 인트로 화면

반응형