본문 바로가기

Android

+/- 만 구현된 간단한 계산기앱(Decimal Format, Flow, StringBuilder)

이번 포스팅에서는 간단한 계산기 앱을 개발하면서 다음 내용을 정리해 볼 것 입니다.

 

1) ConstraintLayout에서 사용하는 Flow의 역할

2) 설정 테마에 따라서 글자 색상을 변경하는 방법

3) DecimalFormat 활용법

4) String vs StringBuilder vs StringBuffer

 

이 4가지 정보에 대해서 정리를 먼저 해보겠습니다.

 

 

Flow

 

ConstraintLayout에서 위젯을 수평 또는 수직으로 체인 형태로 묶는 역할을 합니다. 

ConstraintHelper를 상속받아 나온 형태이기에 constraint_referenced_ids를 통해 나열할 위젯들을 정의합니다.

정렬 방식에는 유의미한게 두 가지가 있는데 1) chain 모드 2) aligned 모드 입니다. 

 

1) app:flow_wrapMode = "chain"

: 요소들을 체인 형태로 묶는데, 위젯의 갯수에 따라 행과 열이 달라질 경우 해당 공간을 메꾸며 체인 형태를 유지합니다.

 

2) app:flow_wrapMode = "aligned"

: 요소들을 체인 형태로 묶는데, 위젯의 갯수에 따라 행과 열이 달라질 경우 공백으로 두어 체인 형태를 유지합니다.

 

 <androidx.constraintlayout.helper.widget.Flow
        android:id="@+id/keyPadFlow"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:padding="10dp"
        app:constraint_referenced_ids="btnKeyPad1, btnKeyPad2, btnKeyPad3, btnKeyPadClear,
                btnKeyPad4, btnKeyPad5, btnKeyPad6, btnKeyPadPlus,
                    btnKeyPad7, btnKeyPad8, btnKeyPad9, btnKeyPadMinus,
                        btnKeyPad0,btnKeyPadEqual,
                           "
        app:flow_horizontalGap="8dp"
        app:flow_maxElementsWrap="4"
        app:flow_wrapMode="chain"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHeight_percent="0.6"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="1" />

    <Button
        android:id="@+id/btnKeyPad1"
        style="@style/numberKeyPad"
        android:onClick="numberClicked"
        android:text="1"
        tools:ignore="MissingConstraints" />

    <Button
        android:id="@+id/btnKeyPad2"
        style="@style/numberKeyPad"
        android:onClick="numberClicked"
        android:text="2"
        tools:ignore="MissingConstraints" />

    <Button
        android:id="@+id/btnKeyPad3"
        style="@style/numberKeyPad"
        android:onClick="numberClicked"
        android:text="3"
        tools:ignore="MissingConstraints" />

    <Button
        android:id="@+id/btnKeyPad4"
        style="@style/numberKeyPad"
        android:onClick="numberClicked"
        android:text="4"
        tools:ignore="MissingConstraints" />

    <Button
        android:id="@+id/btnKeyPad5"
        style="@style/numberKeyPad"
        android:onClick="numberClicked"
        android:text="5"
        tools:ignore="MissingConstraints" />

    <Button
        android:id="@+id/btnKeyPad6"
        style="@style/numberKeyPad"
        android:onClick="numberClicked"
        android:text="6"
        tools:ignore="MissingConstraints" />

    <Button
        android:id="@+id/btnKeyPad7"
        style="@style/numberKeyPad"
        android:onClick="numberClicked"
        android:text="7"
        tools:ignore="MissingConstraints" />

    <Button
        android:id="@+id/btnKeyPad8"
        style="@style/numberKeyPad"
        android:onClick="numberClicked"
        android:text="8"
        tools:ignore="MissingConstraints" />

    <Button
        android:id="@+id/btnKeyPad9"
        style="@style/numberKeyPad"
        android:onClick="numberClicked"
        android:text="9"
        tools:ignore="MissingConstraints" />

    <Button
        android:id="@+id/btnKeyPad0"
        style="@style/numberKeyPad"
        android:onClick="numberClicked"
        android:text="0"
        app:layout_constraintHorizontal_weight="1"
        tools:ignore="MissingConstraints" />

    <Button
        android:id="@+id/btnKeyPadEqual"
        style="@style/operatorKeyPad"
        android:onClick="equalClicked"
        android:text="="
        app:layout_constraintHorizontal_weight="3"
        tools:ignore="MissingConstraints" />

    <Button
        android:id="@+id/btnKeyPadClear"
        style="@style/operatorKeyPad"
        android:onClick="clearClicked"
        android:text="C"
        tools:ignore="MissingConstraints" />


    <Button
        android:id="@+id/btnKeyPadPlus"
        style="@style/operatorKeyPad"
        android:onClick="operatorClicked"
        android:text="+"
        tools:ignore="MissingConstraints" />

    <Button
        android:id="@+id/btnKeyPadMinus"
        style="@style/operatorKeyPad"
        android:onClick="operatorClicked"
        android:text="-"
        tools:ignore="MissingConstraints" />

 

다음처럼 Button들을 constraint_referenced_ids를 통해 id로 다 묶어놓고 maxElementsWrap을 4로두어 

열을 최대 4개까지 둘 수 있도록 했습니다.

constraintVertical_bias를 0~1 사이의 값으로 두어 flow로 셋팅한 뷰그룹이 어디에 맞춰 위치할지 정합니다.

constraintHeight_percent를 통해서도 flow의 높이를 조정해줍니다.

이렇게 Flow를 통해 다른 위젯들을 하나의 체인 형태로 묶어서 편하게 표현할 수 있습니다.


설정 테마에 따른 색 변화

 

1) 설정 테마 바꾸는법

- 휴대폰 설정에 들어가서 Display 영역에 다크모드 활성화합니다.

- 라이트 모드일 때와 다크모드 일 때 어플리케이션에서 알아서 위젯 색상을 변경하는 작업을 수행합니다.

 

2) colors.xml 파일은 res\values\colors 에 존재합니다.   여기다가 resource 파일을 생성하는데,

colors-night 로 이름을 정하여 다크모드일때를 대비한 개발을 해줍니다.

 

다크모드 관련 colors-night 생성

 

res > values > colors > colors.xml(night) 추가됨

 

colors.xml 구성

 

colors.xml과 colors.xml(night)에 동일한 color name을 설정하고 이걸 위젯에 색 설정할 때 @color/~~~ 해서 접근하면 됩니다.

그러면 자동으로 다크모드일땐 colors.xml(night)를 따라갈 것이고 라이트모드는 기본 colors.xml을 따라갈 것 입니다.

 


DecimalFormat

 

숫자 형태를 설정하는 함수입니다.  계산기를 쓸 때 천의 자리수가 되는 경우 ',' 가 생기고 또한 앞에 0이 붙으면

생략하고 숫자를 표현합니다. (07 -> 7)  이러한 숫자 관련 포멧을 설정할 때 쓰이게 됩니다. 

 

private val decimalFormat = DecimalFormat("#,###")


// decimalFormat.format(숫자가 들어가는 변수) 

     val firstNumber = firstNumberText.toString().toBigDecimal()
     val secondNumber = secondNumberText.toString().toBigDecimal()
     val result = when(operatorText.toString()){
           "+" -> decimalFormat.format(firstNumber + secondNumber)
           "-" -> decimalFormat.format(firstNumber - secondNumber)
           else -> Toast.makeText(this, "잘못된 수식입니다.", Toast.LENGTH_SHORT).show()
     }.toString()

 

이런 느낌으로 포멧 설정을 하는것입니다.

Int형으로 쓰되, 범위 때문에 최대한 넓게 잡기 위해서 toBigDecimal()을 써서 선언한 다음

해당 정수형 변수를 decimalFormat.format을 통해서 표현해주면 소위 우리가 알고 있는 포멧으로

셋팅되어 표현될 것 입니다.  (예 : 3천 = 3,000)

 


String / StringBuilder / StringBuffer

 

1) String

: 불변의 문자열입니다.  새로 변경하려면 다른 객체를 만들어서 선언해야합니다.

 

2) StringBuilder

: mutable한 객체입니다.  문자열을 더하거나 빼거나 수정 등 모두 다 가능합니다.

StringBuilder는 동기화를 지원하지 않기 때문에 단일 스레드에서 효율적인 객체입니다.

 

3) StringBuffer

: StringBuilder와 마찬가지로 mutable한 객체입니다.

문자열을 더하거나 빼거나 수정 등 모두 다 가능하며, 동기화를 지원하기 때문에 멀티 스레드에서 

안전하게 활용할 수 있습니다.

 

 

다음 프로젝트에서는 단일 스레드에서 문자열을 더하고 빼고 하는 작업을 하기 때문에 StringBuilder를 활용하겠습니다.

 

[MainActivity.kt]

package com.example.chapter5

import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.example.chapter5.databinding.ActivityMainBinding
import java.text.DecimalFormat

class MainActivity : AppCompatActivity() {
    companion object {
        const val TAG = "MAINACTIVITY"
    }

    private lateinit var binding: ActivityMainBinding
    private val firstNumberText = StringBuilder("")
    private val secondNumberText = StringBuilder("")
    private val operatorText = StringBuilder("")
    private val decimalFormat = DecimalFormat("#,###")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }

    }

    fun numberClicked(v: View) {
        // 엘리스 연산자 사용
        // 버튼일 경우 텍스트 뽑고 버튼이 아닐 경우 공백
        val numberString = (v as? Button)?.text.toString() ?: ""
        val numberText = if(operatorText.isEmpty()) firstNumberText else secondNumberText
        numberText.append(numberString)
        updateEquationTextView()
    }

    fun clearClicked(v: View) {
        firstNumberText.clear()
        secondNumberText.clear()
        operatorText.clear()
        binding.resultTextView.text = ""
        updateEquationTextView()
    }

    @Suppress("IMPLICIT_CAST_TO_ANY")
    fun equalClicked(v: View) {
        if(firstNumberText.isNotEmpty() && secondNumberText.isNotEmpty() && operatorText.isNotEmpty()){
            val firstNumber = firstNumberText.toString().toBigDecimal()
            val secondNumber = secondNumberText.toString().toBigDecimal()
            val result = when(operatorText.toString()){
                "+" -> decimalFormat.format(firstNumber + secondNumber)
                "-" -> decimalFormat.format(firstNumber - secondNumber)
                else -> Toast.makeText(this, "잘못된 수식입니다.", Toast.LENGTH_SHORT).show()
            }.toString()

            binding.resultTextView.text = result
        }
        else {
            Toast.makeText(this, "수식이 잘못되었습니다.", Toast.LENGTH_SHORT).show()
            return
        }
    }

    fun operatorClicked(v: View) {
        val operatorString = (v as? Button)?.text?.toString() ?: ""
        // operator X
        if(firstNumberText.isEmpty()){
            Toast.makeText(this, "먼저 숫자를 입력해주세요", Toast.LENGTH_SHORT).show()
            return
        }
        // secondNumberText가 있을땐 operator X
        if(secondNumberText.isNotEmpty()){
            Toast.makeText(this, "한 개의 연산자에 대해서만 연산이 가능합니다.", Toast.LENGTH_SHORT).show()
            return
        }
        operatorText.append(operatorString)
        updateEquationTextView()
    }

    private fun updateEquationTextView(){
        val firstFormattedNumber = if(firstNumberText.isNotEmpty()) decimalFormat.format(firstNumberText.toString().toBigDecimal()) else ""
        val secondFormattedNumber = if(secondNumberText.isNotEmpty()) decimalFormat.format(secondNumberText.toString().toBigDecimal()) else ""
        binding.equationTextView.text = "$firstFormattedNumber $operatorText $secondFormattedNumber"
    }
}

 

 

이번 포스팅에서 담겨져 있는 주요 내용들에 대해서 설명하긴 했는데, 더해서 kotlin 코드도 설명하고 마무리하겠습니다.

    fun numberClicked(v: View) {
        // 엘리스 연산자 사용
        // 버튼일 경우 텍스트 뽑고 버튼이 아닐 경우 공백
        val numberString = (v as? Button)?.text.toString() ?: ""
        val numberText = if(operatorText.isEmpty()) firstNumberText else secondNumberText
        numberText.append(numberString)
        updateEquationTextView()
    }

 

이 함수는 xml 코드 안에서 onClick을 선언했을때 해당 함수를 .kt파일에서 구현한 것입니다.

보통은 xml에서 onClick과 같은 이벤트를 선언해두지 않고 .kt파일에서 setOnClickListener를 달아서 처리하는데

이러한 방법도 있다 식으로 설명하고자 담았습니다.

numberClicked에서 인자로 가져오는 View 객체는 사실 xml내 선언한 모든 위젯에 속할 수 있기 때문에

어떤 것이 오는지 모르므로 (v as? Button)으로 정의했습니다.

버튼일 경우 text.toString()으로 버튼에 적힌 값이 반환되는 것이고, 버튼이 아닌경우 엘비스 연산자를 통해 공백이 들어올 것입니다.

 

fun clearClicked(v: View) {
   firstNumberText.clear()
   secondNumberText.clear()
   operatorText.clear()
   binding.resultTextView.text = ""
   updateEquationTextView()
}

 

clearClicked 함수를 선택하면 StringBuilder로 선언했던 객체들은 모두 clear 해줍니다.

또한 응답값의 텍스트는 빈 값으로 정의합니다. 

 

    @Suppress("IMPLICIT_CAST_TO_ANY")
    fun equalClicked(v: View) {
        if(firstNumberText.isNotEmpty() && secondNumberText.isNotEmpty() && operatorText.isNotEmpty()){
            val firstNumber = firstNumberText.toString().toBigDecimal()
            val secondNumber = secondNumberText.toString().toBigDecimal()
            val result = when(operatorText.toString()){
                "+" -> decimalFormat.format(firstNumber + secondNumber)
                "-" -> decimalFormat.format(firstNumber - secondNumber)
                else -> Toast.makeText(this, "잘못된 수식입니다.", Toast.LENGTH_SHORT).show()
            }.toString()

            binding.resultTextView.text = result
        }
        else {
            Toast.makeText(this, "수식이 잘못되었습니다.", Toast.LENGTH_SHORT).show()
            return
        }
    }

 

각 StringBuilder의 값이 비어있지 않을 경우에 대해서만 진행하되, 범위가 넓은 정수형으로 셋팅후 연산자의 형태에 따라서

더할지 뺄 지를 정합니다.   이 결과물은 DecimalFormat을 통해 (#,###) 형태의 구조를 맞춰줍니다.

 

 

    fun operatorClicked(v: View) {
        val operatorString = (v as? Button)?.text?.toString() ?: ""
        // operator X
        if(firstNumberText.isEmpty()){
            Toast.makeText(this, "먼저 숫자를 입력해주세요", Toast.LENGTH_SHORT).show()
            return
        }
        // secondNumberText가 있을땐 operator X
        if(secondNumberText.isNotEmpty()){
            Toast.makeText(this, "한 개의 연산자에 대해서만 연산이 가능합니다.", Toast.LENGTH_SHORT).show()
            return
        }
        operatorText.append(operatorString)
        updateEquationTextView()
    }

    private fun updateEquationTextView(){
        val firstFormattedNumber = if(firstNumberText.isNotEmpty()) decimalFormat.format(firstNumberText.toString().toBigDecimal()) else ""
        val secondFormattedNumber = if(secondNumberText.isNotEmpty()) decimalFormat.format(secondNumberText.toString().toBigDecimal()) else ""
        binding.equationTextView.text = "$firstFormattedNumber $operatorText $secondFormattedNumber"
    }

 

각 StringBuilder의 상태를 가지고 계산기 앱에 대한 로직을 구현할 수 있고 결과 또한 반환할 수 있습니다.

이상 간단한 계산기 앱을 통한 코틀린 기능 정리가 있었습니다.   다음 포스팅에는 스톱워치를 구현하면서 필요한 기능과 개념을

공부해보도록 하겠습니다.    정말 감사합니다. 

반응형