[Must Have Joyce의 안드로이드 앱프로그래밍] 9장 QR 코드 리더기: 카메라, 뷰 바인딩, 구글 ML 키트

알린·2024년 1월 26일
0

안드로이드 젯팩(Jetpack)

  • 구글 안드로이드팀에서 공식 발표한 라이브러리 모음

Jetpack의 이점

  • 안정성
  • 간편성

주요 라이브러리

  • AppSearch: 전체 텍스트 검색으로 사용자를 위한 맞춤 인앱 검색 기능 구현에 사용
  • CameraX: 앱에 카메라 기능 제공
  • Compose: 선언형 UI 중의 하나로 더 적은 코드를 사용하여 효율적인 개발 UI 제공
  • Data Binding: 뷰가 선언적 형식을 사용하여 데이터와 결합이 되도록 함
  • LiveData: 데이터 변경을 실시간으로 뷰에 반영
  • WorkManager: 백그라운드 작업, 비동기 작업 예약 등을 제공
  • Navigation: 액티비티, 프래그먼트 간 화면 이동을 용이하게 제공
  • Test: 테스트 관련 여러 유틸리티 제공
  • ViewBinding: 뷰 컴포넌트를 이용해 편리하게 뷰와 소통하는 기능 제공

구글 ML 키트

  • 구글 머신 러닝 기술을 모바일 기기에서 사용할 수 있게 해주는 라이브러리
  • 사용하기 쉬운 API 제공
  • 바코드 스캐닝, 얼굴 인식, 텍스트 인식과 같은 기능 구현 시 사용 가능

대표적인 API

  • 바코드 스캐닝 API: 바코드를 스캔하고 해석
  • 얼굴 인식 API: 얼굴을 인식하거나 얼굴의 요소들 인식
  • 텍스트 인식 API: 이미지로부터 텍스트를 인식하거나 추출
  • 포즈 인식 API: 사람 몸의 자세를 실시간으로 인식
  • 언어 감지 API: 주어진 텍스트가 쓰여진 언어가 무엇인지 알려줌

뷰 바인딩(ViewBinding)

  • 보일러 플레이트 코드(간단하지만 반복적인 코드)를 없애기 위한 플러그인
  • 뷰 컴포넌트를 이용해 편리하게 뷰와 소통하는 기능 제공
  1. 뷰 바인딩이 활성화
  2. 모든 xml 파일이 자동으로 각각 바인딩 클래스 생성
  3. 뷰를 활용할 때 각각의 바인딩 클래스 객체를 이용

👉 뷰 바인딩 관련 공식 Android 문서

뷰 바인딩 파일 생성 후 확인하기

  1. [app] ➡️ [Gradle Scripts] ➡️ build.gradle(Module : app)에 다음 코드 추가
// 안드로이드 스튜디오 4.0 이상일 때
android {
    ...
    buildFeatures {
        viewBinding = true
    }
}
// 안드로이드 스튜디오 3.6 ~ 4.0. 3.6보다 낮으면 안됨
android {
        ...
        viewBinding {
        	enabled true
    }
}
  1. 상단 상태 표시줄에 뜨는 [Sync Now] 클릭해 새로운 설정 반영
  2. [Build] ➡️ [Make Project] 선택해 프로젝트 빌드 진행
  3. Android에서 Project 모드 선택
  4. [app] ➡️ [build] ➡️ [generated] ➡️ [data_building_base_class_source_out] ➡️ [ActivityMainBinding.java] 클릭
  5. ConstraintLayoutrootView라는 변수로 생성된 것 확인 가능
    activity_main.xml에서 생성한 버튼 뷰들의 ID 값을 가진 변수가 선언된 것 확인 가능
    👉 ID값을 지정하지 않은 버튼 뷰는 변수 생성 안됨

액티비티에서 뷰 바인딩 사용하기

  • 모든 바인딩 클래스는 루트 뷰를 반환하는 getRoot() 함수를 갖고 있음
    👉 getRoot() 함수를 호출하면 레이아웃 파일의 루트 뷰인 ConstraintLayout을 반환함

📚 루트 뷰란?

  • 뷰들은 계층 구조를 가지고 레이아웃을 구성

    👉 루트 뷰: 모든 뷰들을 포함하는 뷰
  1. MainActivity.kt에 다음 코드 작성
class MainActivity : AppCompatActivity() {
    private lateinit var binding : ActivityMainBinding
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 바인딩 클래스에 포함된 inflate() 함수를 실행해 바인딩 클래스의 객체 생성
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root  // 바인딩 객체의 root 뷰 참조
        setContentView(view)  // 생성한 뷰 설정

    }
}

애플리케이션 구현

라이브러리 추가

  1. 모듈 수준의 build.gradle 파일 내 dependencies 내에 다음 코드 추가해 종속 항목 선언
    👉 cameraX 관련 공식 Android 문서
dependencies {
    // CameraX core library using the camera2 implementation
    val camerax_version = "1.3.0-alpha04"
    // The following line is optional, as the core library is included indirectly by camera-camera2
    implementation("androidx.camera:camera-core:${camerax_version}")
    implementation("androidx.camera:camera-camera2:${camerax_version}")
    // If you want to additionally use the CameraX Lifecycle library
    implementation("androidx.camera:camera-lifecycle:${camerax_version}")
    // If you want to additionally use the CameraX View class
    implementation("androidx.camera:camera-view:${camerax_version}")
    // If you want to additionally add CameraX ML Kit Vision Integration
    implementation("com.goole.mlkit:barcode-scanning:${camerax_version}")
}

카메라 미리보기 화면 구현

ListenableFuture형 변수

  • ListenableFuture에 태스크가 제대로 끝났을 때의 동작 지정 가능
// 변수 선언
private lateinit var cameraProvideFuture : ListenableFuture<ProcessCameraProvider>
fun startCamera() {
    // cameraProvideFuture에 객체의 참조값 할당
    cameraProvideFuture = ProcessCameraProvider.getInstance(this)
	// cameraProvideFuture 태스크가 끝나면 실행
	cameraProvideFuture.addListener(Runnable {
    	// ProcessCameraProvider 객체 가져오기
    	// ProcessCameraProvider: 카메라의 생명 주기를 액티비티 생명 주기에 바인드 해줌
    	val cameraProvider = cameraProvideFuture.get()
        // 미리보기 객체 가져오기
        val preview = getPreview()
        // DEFAULT_BACK_CAMERA(후면 카메라) 선택
        val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
        // preview(미리보기) 쓰기 선택
        cameraProvider.bindToLifecycle(this, cameraSelector, preview)
    }, ContextCompat.getMainExecutor(this))
}

CameraSelector

  • 어떤 카메라를 사용할 것인지 선택
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

DEFAULT_BACK_CAMERA: 후면 카메라
DEFAULT_FRONT_CAMERA: 전면 카메라

bindToLifecycle

  • 생명 주기 바인드해줄 카메라의 생명주기 선택
cameraProvider.bindToLifecycle(this, cameraSelector, preview)

미리보기, 이미지 분석, 이미지 캡쳐 중 선택

카메라 권한 승인

  • 카메라를 쓰기 위해 유저에게 권한을 요청하는 처리를 구현
  • AndroidManifest.xml 파일 내 manifest 내에 다음 코드 추가
<uses-feature android:name="android.hardware.camera.any" />
<uses-permission android:name="android.permission.CAMERA" />
  • MainActivity.kt 파일에 다음 코드 작성
class MainActivity : AppCompatActivity() { 
    // 태그 기능 코드 => 나중에 권한을 요청한 후 결과를 onRequestPermissionsResult에서 받을 떄 필요
    // 0과 같거나 큰 양수이기만 하면 어떤 수든 상관없음
    private val PERMISSIONS_REQUEST_CODE = 1
    // 카메라 권한 지정
    private val PERMISSIONS_REQUIRED = arrayOf(android.Manifest.permission.CAMERA)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view) 

        if (!hasPermissions(this)) {  // 권한이 없을 때
            // 카메라 권한 요청
            requestPermissions(PERMISSIONS_REQUIRED, PERMISSIONS_REQUEST_CODE)
        } else {
            // 이미 권한 있을 때
            startCamera()
        }
    }

    // all => PERMISSIONS_REQUIRED 배열의 원소가 모두 조건문을 만족하면 true, 아니면 false 반환
    fun hasPermissions(context: Context) = PERMISSIONS_REQUIRED.all {
        ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
    }

    // 권한 요청 콜백 함수
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)

        // onCreate() 메서드에서 requestPermissions의 인수로 넣은 PERMISSIONS_REQUEST_CODE와 맞는지 확인
        if (requestCode == PERMISSIONS_REQUEST_CODE) {  // 권한 수락 시
            if (PackageManager.PERMISSION_GRANTED == grantResults.firstOrNull()){
                Toast.makeText(this@MainActivity, "권한 요청이 승인되었습니다.", Toast.LENGTH_LONG).show()
                startCamera()
            } else {  // 권한 거부 시
                Toast.makeText(this@MainActivity, "권한 요청이 거부되었습니다.", Toast.LENGTH_LONG).show()
                finish()
            }
        }
    }
}

실행 결과

  • 앱 최초 실행 시 카메라 권한 요구 팝업 출력

QR 코드 인식 기능 구현

CameraX의 Analyzer 클래스 구현

  • ImageAnalysis.Analyzer 인터페이스는 analyze() 함수 하나만 오버라이드 하면 됨
import android.annotation.SuppressLint
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage

class QRCodeAnalyzer : ImageAnalysis.Analyzer{
    // 바코드 스캐닝 객체 생성
    private val scanner = BarcodeScanning.getClient()

    @SuppressLint("UnsafeOptInUsageError")
    override fun analyze(imageProxy: ImageProxy) {
        val mediaImage = imageProxy.image
        if (mediaImage != null) {
            // 이미지가 찍힐 단시 카메라의 회전 각도를 고려해 입력 이미지 생성
            val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)

            // scanner.process(image) 통해 이미지 분석
            // SuccessListener, FailureListener, CompleteListener를 각각 달아주어 결과 확인 가능
            scanner.process(image).addOnCanceledListener {
            }
        }
    }
}

🛠️ Error Fix
BarcodeScanning.getClient() 작성 시 빨간줄 에러가 뜬다면
Alt + Enter를 누르다보면 앱모듈 수준 build.gradle에 자동으로 필요한 mlkit 의존성을 주입해줌

implementation("com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.0")

👉 이미지 분석 관련 Android 공식 문서

  • MainActivity와 QRCodeAnalyzer 사이onDetectListener 인터페이스 구현

onDetectListener.kt

interface onDetectListener {
   fun onDetect(msg : String)
}
  • onDetectListener 인터페이스와 QRCodeAnalyzer 연동 구현
class QRCodeAnalyzer(val onDetectListener: onDetectListener) : ImageAnalysis.Analyzer{
    // 바코드 스캐닝 객체 생성
    private val scanner = BarcodeScanning.getClient()

    @SuppressLint("UnsafeOptInUsageError")
    override fun analyze(imageProxy: ImageProxy) {
        val mediaImage = imageProxy.image
        if (mediaImage != null) {
            // 이미지가 찍힐 단시 카메라의 회전 각도를 고려해 입력 이미지 생성
            val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)

            // scanner.process(image) 통해 이미지 분석
            // SuccessListener, FailureListener, CompleteListener를 각각 달아주어 결과 확인 가능
            scanner.process(image).addOnSuccessListener { qrCodes ->
                // QR 코드가 성공적으로 찍혔을 시
                for (qrCode in qrCodes) {
                    // rawValue가 존재하면 rawValue값을 보내고, null이면 빈 문자열 보냄
                    onDetectListener.onDetect(qrCode.rawValue ?: "")
                }
            }.addOnFailureListener{
                it.printStackTrace()
            }.addOnCompleteListener{
                imageProxy.close()
            }
        }
    }
}
  • MainActivity에서 Analyzer와 카메라 연동 구현
    fun getImageAnalysis() : ImageAnalysis {
        val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
        val imageAnalysis = ImageAnalysis.Builder().build()

        // QRCodeAnalyzer 객체 생성 후 setAnalyzer() 함수의 인수로 넣어줌
        // object를 통해 OnDetectListener 인터페이스 객체 생성 후 onDetect() 함수를 오버라이드
        imageAnalysis.setAnalyzer(cameraExecutor, QRCodeAnalyzer(object : OnDetectListener {
            override fun onDetect(msg: String) {
                // onDetect() 함수가 QRCodeAnalyzer에서 불렀을 때 행동 정의
                Toast.makeText(this@MainActivity, "${msg}", Toast.LENGTH_SHORT).show()
            }
        }))
        return imageAnalysis
    }
    fun startCamera() {
        // cameraProvideFuture에 객체의 참조값 할당
        cameraProvideFuture = ProcessCameraProvider.getInstance(this)
        // cameraProvideFuture 태스크가 끝나면 실행
        cameraProvideFuture.addListener(Runnable {
            // ProcessCameraProvider 객체 가져오기
            // ProcessCameraProvider: 카메라의 생명 주기를 액티비티 생명 주기에 바인드 해줌
            val cameraProvider = cameraProvideFuture.get()
            // 미리보기 객체 가져오기
            val preview = getPreview()
            // 이미지분석 객체 가져오기
            val imageAnalysis = getImageAnalysis()
            // DEFAULT_BACK_CAMERA(후면 카메라) 선택
            val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
            // preview(미리보기) 쓰기 선택
            cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageAnalysis)
        }, ContextCompat.getMainExecutor(this))
    }

전체 코드

MainActivity.kt

import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Toast
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import com.example.qrcodereaderapp.databinding.ActivityMainBinding
import com.google.common.util.concurrent.ListenableFuture
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors

class MainActivity : AppCompatActivity() {
    // 바인딩 변수 생성
    private lateinit var binding : ActivityMainBinding
    // ListenableFuture형 변수 생성 => ListenableFuture에 태스크가 제대로 끝났을 때 동작 지정 가능
    private lateinit var cameraProvideFuture : ListenableFuture<ProcessCameraProvider>
    // 태그 기능 코드 => 나중에 권한을 요청한 후 결과를 onRequestPermissionsResult에서 받을 떄 필요
    // 0과 같거나 큰 양수이기만 하면 어떤 수든 상관없음
    private val PERMISSIONS_REQUEST_CODE = 1
    // 카메라 권한 지정
    private val PERMISSIONS_REQUIRED = arrayOf(android.Manifest.permission.CAMERA)
    // onDetect() 함수가 여러 번 호출되는 것 방지 기능 변수 선언
    private var isDetected = false
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 바인딩 클래스에 포함된 inflate() 함수를 실행해 바인딩 클래스의 객체 생성
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root  // 바인딩 객체의 root 뷰 참조
        setContentView(view)  // 생성한 뷰 설정

        if (!hasPermissions(this)) {  // 권한이 없을 때
            // 카메라 권한 요청
            requestPermissions(PERMISSIONS_REQUIRED, PERMISSIONS_REQUEST_CODE)
        } else {
            // 이미 권한 있을 때
            startCamera()
        }
    }

    // all => PERMISSIONS_REQUIRED 배열의 원소가 모두 조건문을 만족하면 true, 아니면 false 반환
    fun hasPermissions(context: Context) = PERMISSIONS_REQUIRED.all {
        ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
    }

    // 권한 요청 콜백 함수
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)

        // onCreate() 메서드에서 requestPermissions의 인수로 넣은 PERMISSIONS_REQUEST_CODE와 맞는지 확인
        if (requestCode == PERMISSIONS_REQUEST_CODE) {  // 권한 수락 시
            if (PackageManager.PERMISSION_GRANTED == grantResults.firstOrNull()){
                Toast.makeText(this@MainActivity, "권한 요청이 승인되었습니다.", Toast.LENGTH_LONG).show()
                startCamera()
            } else {  // 권한 거부 시
                Toast.makeText(this@MainActivity, "권한 요청이 거부되었습니다.", Toast.LENGTH_LONG).show()
                finish()
            }
        }
    }
    
    // 다시 사용자의 포커스가 MainActivity로 돌아온다면 다시 QR 코드를 인식할 수 있는 기능
    // onResume() 함수를 오버라이드해 isDetected를 false로 다시 돌려줌
    override fun onResume() {
        super.onResume()
        isDetected = false
    }
    
    fun getImageAnalysis() : ImageAnalysis {
        val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
        val imageAnalysis = ImageAnalysis.Builder().build()

        // QRCodeAnalyzer 객체 생성 후 setAnalyzer() 함수의 인수로 넣어줌
        // object를 통해 OnDetectListener 인터페이스 객체 생성 후 onDetect() 함수를 오버라이드
        imageAnalysis.setAnalyzer(cameraExecutor, QRCodeAnalyzer(object : OnDetectListener {
            override fun onDetect(msg: String) {
                // onDetect() 함수가 QRCodeAnalyzer에서 불렀을 때 행동 정의
                if (!isDetected) {  // 한 번도 QR 코드가 인식된 적 없는지 검사(중복 실행 방지)
                    isDetected = true  // 데이터가 감지되었으므로 true
                    val intent = Intent(this@MainActivity, ResultActivity::class.java)
                    intent.putExtra("msg", msg)  // 다음 액티비티로 데이터를 키-쌍의 형태로 넘기기 가능
                    startActivity(intent)
                }
            }
        }))
        return imageAnalysis
    }
    fun startCamera() {
        // cameraProvideFuture에 객체의 참조값 할당
        cameraProvideFuture = ProcessCameraProvider.getInstance(this)
        // cameraProvideFuture 태스크가 끝나면 실행
        cameraProvideFuture.addListener(Runnable {
            // ProcessCameraProvider 객체 가져오기
            // ProcessCameraProvider: 카메라의 생명 주기를 액티비티 생명 주기에 바인드 해줌
            val cameraProvider = cameraProvideFuture.get()
            // 미리보기 객체 가져오기
            val preview = getPreview()
            // 이미지분석 객체 가져오기
            val imageAnalysis = getImageAnalysis()
            // DEFAULT_BACK_CAMERA(후면 카메라) 선택
            val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
            // preview(미리보기) 쓰기 선택
            cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageAnalysis)
        }, ContextCompat.getMainExecutor(this))
    }

    fun getPreview() : Preview {
        val preview : Preview = Preview.Builder().build()  // Preview 객체 생성
        // setSurfaceProvider(): Preview 객체에 SurfaceProvider를 설정해줌
        //SurfaceProvider: Preview에 Surface(화면에 보여지는 픽셀들이 모여 있는 객체)를 제공해주는 인터페이스
        preview.setSurfaceProvider(binding.barcodePreview.getSurfaceProvider())
        return preview
    }
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.camera.view.PreviewView
        android:id="@+id/barcode_preview"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        />
</androidx.constraintlayout.widget.ConstraintLayout>

QRCodeAnalyzer.kt

import android.annotation.SuppressLint
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage

class QRCodeAnalyzer(val onDetectListener: OnDetectListener) : ImageAnalysis.Analyzer{
    // 바코드 스캐닝 객체 생성
    private val scanner = BarcodeScanning.getClient()

    @SuppressLint("UnsafeOptInUsageError")
    override fun analyze(imageProxy: ImageProxy) {
        val mediaImage = imageProxy.image
        if (mediaImage != null) {
            // 이미지가 찍힐 단시 카메라의 회전 각도를 고려해 입력 이미지 생성
            val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)

            // scanner.process(image) 통해 이미지 분석
            // SuccessListener, FailureListener, CompleteListener를 각각 달아주어 결과 확인 가능
            scanner.process(image).addOnSuccessListener { qrCodes ->
                // QR 코드가 성공적으로 찍혔을 시
                for (qrCode in qrCodes) {
                    // rawValue가 존재하면 rawValue값을 보내고, null이면 빈 문자열 보냄
                    onDetectListener.onDetect(qrCode.rawValue ?: "")
                }
            }.addOnFailureListener{
                it.printStackTrace()
            }.addOnCompleteListener{
                imageProxy.close()
            }
        }
    }
}

OnDetectListener.kt

interface OnDetectListener {
    fun onDetect(msg : String)
}

ResultActivity.kt

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.example.qrcodereaderapp.databinding.ActivityResultBinding

class ResultActivity : AppCompatActivity() {
    lateinit var binding: ActivityResultBinding
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityResultBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        val result = intent.getStringExtra("msg") ?: "데이터가 존재하지 않습니다."
        setUI(result)
    }

    private fun setUI(result: String) {
        binding.tvResult.text = result
        binding.btnGoBack.setOnClickListener {
            finish()
        }
    }
}

activity_result.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ResultActivity">

    <TextView
        android:id="@+id/tv_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="168dp"
        android:text="@string/title"
        android:textSize="24sp"
        android:textStyle="bold"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv_result"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="결괏값 자리"
        android:textColor="@color/gray"
        android:textSize="18sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/tv_title" />


    <Button
        android:id="@+id/btn_go_back"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="68dp"
        android:backgroundTint="@color/pink"
        android:text="@string/back"
        android:textSize="15sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

실행화면

업로드중..

profile
Android 짱이 되고싶은 개발 기록 (+ ios도 조금씩,,👩🏻‍💻)

0개의 댓글