S. Serkan Çay

Software Engineer, Android

UI State Management in Jetpack Compose

Kullanıcıya gösterilen arayüzün mevcut durumunu tutan ve herhangi bir anda güncellenebilen UI State, doğru bir şekilde kurgulanmalıdır. Unidirectional Data Flow(UDF)’a uygun olarak tek bir yerden değiştirilebilmeli ve kaynak tek bir yerden sağlanmalıdır. Bu sayede tutarsızlıklardan dolayı oluşabilecek hatalar minimum seviyeye indirgenebilmektedir. Jetpack Compose ile bunu nasıl yapabileceğimize göz atalım.

Gerçek Hayat Örneği: SMS OTP Doğrulama

Konunun daha net anlaşılabilmesi için yazı boyunca SMS OTP doğrulama senaryosu üzerinden gidilecektir.

Örnek uygulamada OTP kodu kullanıcıdan input olarak alınmaktadır. Kodun geçerli veya geçersiz olma durumuna göre input altında bir mesaj gösterilmektedir.

Verinin Modellenmesi

Ekranda gösterilen verileri analiz ettiğimizde; en üstte bir bilgilendirme yazısı, OTP kodunun uzunluğu, kullanıcıdan alınan input değeri ve OTP kodunun statüsüne göre bilgilendirme içeren değerler bulunmaktadır.

Bu noktada UI modelimizi aşağıdaki gibi oluşturabiliriz.

const val DEFAULT_OTP_SIZE = 6

data class OtpUIModel(
    val infoMessage: String = "",
    val input: String = "",
    val inputSize: Int = DEFAULT_OTP_SIZE,
    val status: OtpStatusUIModel = OtpStatusUIModel.Empty
)

Model içerisindeki input değeri hem kullanıcıdan alınan hem de arayüzde kullanıcıya gösterilen bir değerdir. Arayüzü tek bir kaynaktan beslemek gibi bir amaç olduğundan dolayı model içerisinde tutulmaktadır.

Farklı bir kaynaktan(ör. Network çağrısı) gelecek cevaba göre güncellenecek OTP statüsüne ait UI model ise aşağıdaki gibidir.

sealed interface OtpStatusUIModel {
    data class Verified(val message: String) : OtpStatusUIModel
    data class Unverified(val message: String) : OtpStatusUIModel
    data object Empty : OtpStatusUIModel
}

State Kavramı

Verilerin yanı sıra kullanıcıya gösterilmesi gereken durumlar vardır. Bunlar genel olarak Network çağrılarından veya uzun süren I/O işlemlerinden ortaya çıkmaktadır. Bir Network isteğinin cevabı beklenirken kullanıcıya bir Loading ekranı gösterilebilir ya da hata durumunda farklı bir ekrana geçilebilir. Bu işlemler arayüzün bir State’i tutularak gerçekleştirilebilir.

State yönetimindeki en önemli nokta farklı State’lere geçerken verilerin korunabilmesi ve aynı zamanda tek bir kaynaktan beslenebilmesini sağlamaktır. Örneğimize dönecek olursak; ekran açılırken OTP sürecinin başlatılabilmesi için bir Network isteği atıldığını varsayalım. Kullanıcıdan gerekli Input alındıktan sonra tekrar Network isteği atılmakta ve Network kaynaklı beklenmedik bir hata oluşmaktadır. Eğer State geçileri arasında veriler korunmuyorsa ilk Network isteğinden alınan veriyi UI’ya taşımak için tekrar istek atılması gerekmektedir.

State Yönetimi

Arayüzde oluşacak durumları göz önüne alarak UI State’imizi aşağıdaki gibi oluşturabiliriz.

sealed interface OtpUIState {

    val data: OtpUIModel

    data class Empty(override val data: OtpUIModel = OtpUIModel()) : OtpUIState
    data class Loading(override val data: OtpUIModel) : OtpUIState
    data class Error(val message: String, override val data: OtpUIModel) : OtpUIState
    data class Success(override val data: OtpUIModel) : OtpUIState

}

Her bir state, arayüze ait veri modelini tutmaktadır. Böylece veri tutarlılığı sağlanabilmektedir.

State için View Model içerisinde bir Flow oluşturalım.

private val _uiState = MutableStateFlow<OtpUIState>(OtpUIState.Empty())
    val uiState get() = _uiState.asStateFlow()

OTP sürecini başlatmak için bir Network çağrısı atıldığını varsayalım. Çağrı atıldığı esnada Loading ve Success geçişlerinde Flow update edilerek veri düzenlenmektedir. Bu noktada Flow’un update edilerek mevcut veriden bir kopya alınarak düzenlenmesi önem taşımaktadır.

    init {
        getOTPInformation()
    }

    private fun getOTPInformation() {
        viewModelScope.launch {
            _uiState.update { currentState ->
                OtpUIState.Loading(data = currentState.data)
            }
            delay(500)
            _uiState.update { currentState ->
                OtpUIState.Success(
                    data = currentState.data.copy(infoMessage = "Enter the OTP code sent via SMS")
                )
            }
        }
    }

Event Yönetimi

Kullanıcıdan alınan değerleri remember API ile Composable içerisinde tutmak; verinin tek bir yerden yönetilmesini engelleyerek, hataya açık bir durum oluşturur. Bu yüzden değerler bir Event ile View Model’e taşınmalıdır. Eğer Composable içerisindeki input sadece arayüzü ilgilendiriyorsa remember ile tutulabilir.

Örneğimizde kullanıcıdan Compose TextField ile OTP kod değeri alınmaktadır. Bu işlem için gönderilecek Event aşağıdaki gibidir.

sealed interface OtpEvent {
    data class OnInputChange(val input: String): OtpEvent
}

View Model içerisindeki onEvent() metodunda, Compose UI tarafından gönderilen Event’leri alarak işlemlerimizi gerçekleştirebiliriz.

fun onEvent(event: OtpEvent) {
        when (event) {
            is OtpEvent.OnInputChange -> {
                viewModelScope.launch {
                    _uiState.update { currentState ->
                        OtpUIState.Success(
                            data = currentState.data.copy(input = event.input)
                        )
                    }
                }
                if (event.input.length == uiState.value.data.inputSize) {
                    verifyOTP(event.input)
                }
            }
        }
    }

TextField üzerinde bir değişiklik yapıldığında OnInputChange Event’i View Model’a bildirilir. Böylelikle UI State içerisindeki veri güncellenir. Eğer kullanıcı inputSize değeri kadar(örneğimizde uzunluk 6’dır) değer girerse verifyOTP() metodu çağırılarak doğrulama yapılır. Böylece kullanıcı beklenen uzunlukta kod girdiğinde otomatik olarak doğrulama başlatılmış olur.

private fun verifyOTP(input: String) {
        viewModelScope.launch {
            _uiState.update { currentState ->
                OtpUIState.Loading(data = currentState.data)
            }
            delay(500)
            _uiState.update { currentState ->
                if (CORRECT_OTP_CODE == input) {
                    OtpUIState.Success(
                        data = currentState.data.copy(
                            input = "",
                            status = OtpStatusUIModel.Verified("Verified successfully")
                        )
                    )
                } else if (WRONG_OTP_CODE == input) {
                    OtpUIState.Success(
                        data = currentState.data.copy(
                            input = "",
                            status = OtpStatusUIModel.Unverified("Verification failed")
                        )
                    )
                } else {
                    OtpUIState.Error(
                        message = "An unexpected error occurred",
                        data = currentState.data.copy()
                    )
                }
            }
        }
    }

Konunun daha net anlaşılması açısından aşağıdaki iki değer doğru ve yanlış kod olarak belirlenmiştir. Bu sayede doğru ve hatalı kod girişindeki davranışı görmemiz mümkün olmaktadır.

companion object {
        const val CORRECT_OTP_CODE = "111111"
        const val WRONG_OTP_CODE = "000000"
    }

Sonuç olarak OTP statüsü bu değerlere göre güncellenmekte ve farklı bir hata durumunda Error State gönderilmektedir.

Arayüzün Tasarlanması

OTP ekranı için oluşturulan State’leri Composable tarafına taşımak için bir Route oluşturarak View Model’daki değerleri okumamız gerekmektedir.

@Composable
fun OtpRoute(
    viewModel: OtpViewModel = viewModel()
) {

    val uiState = viewModel.uiState.collectAsState()

    OtpScreen(
        uiState = uiState.value,
        onEvent = { event ->
            viewModel.onEvent(event)
        }
    )
}

OTP Screen, UI State değeri alıp event iletmektedir. Bu sayede UDF yaklaşımına uygun şekilde veriler aşağı doğru, Event’ler yukarı doğru iletilmektedir.

@Composable
fun OtpScreen(
    uiState: OtpUIState,
    onEvent: (OtpEvent) -> Unit
) {
    when (uiState) {
        is OtpUIState.Empty -> {}

        is OtpUIState.Loading -> {}

        is OtpUIState.Error -> {}

        is OtpUIState.Success -> {}
    }
}

Loading ve Error State için bu örneğimizde basit iki ekran tasarlanmıştır. Bu sayede son kullanıcı uzun bir işlem olduğunda ekranda bir Progress görecektir. Ayrıca hata durumunda ise hata mesajı ekranda gösterilmiştir.

is OtpUIState.Loading -> {
      Box(modifier = Modifier.fillMaxSize()) {
          CircularProgressIndicator(
              modifier = Modifier
                  .size(48.dp)
                  .align(Alignment.Center)
          )
      }
  }

  is OtpUIState.Error -> {
      Box(modifier = Modifier.fillMaxSize()) {
          Text(
              modifier = Modifier.align(Alignment.Center),
              text = uiState.message,
              color = Color.Red
          )
      }
  }

Success State içerisinde modele ait veriler gösterilmiş ve BasicTextField ile alınan değer Event olarak View Model’a gönderilmiştir. Açıklamak gerekirse, value olarak UI State içerisinde bulunan değer belirlenmiş ve değişiklik olduğunda(kullanıcının kod girmesi) Event ile View Model’a yeni değer gönderilmiştir. Ayrıca OTP status durumuna göre BasicTextField altında bilgilendirme mesajı gösterilmiştir.

is OtpUIState.Success -> {

      Column(modifier = Modifier.padding(16.dp)) {
          Text(text = uiState.data.infoMessage)
          Spacer(modifier = Modifier.height(16.dp))
          BasicTextField(
              value = uiState.data.input,
              onValueChange = {
                  onEvent.invoke(OtpEvent.OnInputChange(it))
              },
              decorationBox = {
                  Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
                      repeat(uiState.data.inputSize) { index ->
                          val char = when {
                              index >= uiState.data.input.length -> ""
                              else -> uiState.data.input[index].toString()
                          }
                          Text(
                              modifier = Modifier
                                  .size(48.dp)
                                  .border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
                                  .wrapContentSize(),
                              text = char,
                              textAlign = TextAlign.Center
                          )
                      }
                  }
              }
          )
          when (val otpStatus = uiState.data.status) {
              is OtpStatusUIModel.Verified -> {
                  Text(
                      modifier = Modifier.padding(vertical = 8.dp),
                      text = otpStatus.message,
                      color = Color.Green
                  )
              }

              is OtpStatusUIModel.Unverified -> {
                  Text(
                      modifier = Modifier.padding(vertical = 8.dp),
                      text = otpStatus.message,
                      color = Color.Red
                  )
              }

              is OtpStatusUIModel.Empty -> {}
          }
      }


  }

Sonuç

Jetpack Compose ile yazılan bir arayüzün nasıl bir State yönetimine sahip olabileceği gösterilmiştir. Akışın tek yönlü olması, oluşabilecek tutarsızlıkların önüne geçerek hata yapma ihtimalini indirgemiştir. Sonuç olarak, karmaşık ekranlarda View Model ve Composable View arasındaki bu uyum verinin doğruluğunu korumuş ve okunurluğu artırmıştır. Ayrıca, tüm State’ler UI Model’in bir kopyasını tuttuğu için State değişimlerinde tutarsızlık yaşanma ihtimali daha azdır.

Yazıda kullanılan projeye ait kodlara aşağıdaki bağlantıdan ulaşılabilir.
https://github.com/serkancay/compose-state-management

Başlıktaki görsel: (Photo by Pixabay) https://www.pexels.com/photo/gray-asphalt-road-during-nighttime-417018/

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir