أحداث واجهة المستخدم

أحداث واجهة المستخدم هي إجراءات يجب معالجتها في طبقة واجهة المستخدم، إما من خلال واجهة المستخدم أو من خلال ViewModel. النوع الأكثر شيوعًا من الأحداث هو أحداث المستخدم. ينتج المستخدم أحداث المستخدم من خلال التفاعل مع التطبيق، مثلاً من خلال النقر على الشاشة أو إنشاء إيماءات. بعد ذلك، تستهلك واجهة المستخدم هذه الأحداث باستخدام معاودة الاتصال، مثل دوال لامدا المحدّدة على عناصر مركّبة مختلفة.

عادةً ما يكون ViewModel مسؤولاً عن معالجة منطق العمل لحدث مستخدم معيّن، مثلاً، عندما ينقر المستخدم على زر لتحديث بعض البيانات. عادةً ما يعالج ViewModel ذلك من خلال عرض الدوال التي يمكن لواجهة المستخدم استدعاؤها. قد تتضمّن أحداث المستخدم أيضًا منطق سلوك واجهة المستخدم الذي يمكن لواجهة المستخدم معالجته مباشرةً، مثلاً، الانتقال إلى شاشة مختلفة أو عرض Snackbar.

في حين أنّ منطق العمل يظلّ نفسه للتطبيق نفسه على منصات الأجهزة الجوّالة أو أشكال الأجهزة المختلفة، فإنّ منطق سلوك واجهة المستخدم هو تفصيل في التنفيذ قد يختلف بين هذه الحالات. تحدّد صفحة طبقة واجهة المستخدم هذه الأنواع من المنطق على النحو التالي:

  • يشير منطق العمل إلى ما يجب فعله عند حدوث تغييرات في الحالة، مثلاً، إجراء دفعة أو تخزين الإعدادات المفضَّلة للمستخدم. عادةً ما تعالج طبقة النطاق وطبقة البيانات هذا المنطق. في كل أنحاء هذا الدليل، يتم استخدام فئة مكوّنات البنية ViewModel كحلّ مُحدّد للفئات التي تعالج منطق العمل.
  • يشير منطق سلوك واجهة المستخدم أو منطق واجهة المستخدم إلى كيفية عرض تغييرات الحالة، مثلاً، منطق التنقّل أو كيفية عرض الرسائل للمستخدم. تعالج واجهة المستخدم هذا المنطق.

شجرة قرارات أحداث واجهة المستخدم

يعرض المخطط التالي شجرة قرارات للعثور على أفضل نهج لمعالجة حالة استخدام حدث معيّن. ويشرح بقية هذا الدليل هذه النُهج بالتفصيل.

إذا كان الحدث من ViewModel، عدِّل حالة واجهة المستخدم. إذا كان الحدث قد نشأ في واجهة المستخدم ويتطلّب منطقًا تجاريًا، فوِّض هذا المنطق إلى ViewModel. إذا كان الحدث قد نشأ في واجهة المستخدم ويتطلّب منطق سلوك واجهة المستخدم، عدِّل حالة عنصر في واجهة المستخدم مباشرةً في واجهة المستخدم.
الشكل 1. شجرة قرارات لمعالجة الأحداث

معالجة أحداث المستخدم

يمكن لواجهة المستخدم معالجة أحداث المستخدم مباشرةً إذا كانت هذه الأحداث مرتبطة بتعديل حالة أحد عناصر واجهة المستخدم، مثلاً، حالة عنصر قابل للتوسيع. إذا كان الحدث يتطلب تنفيذ منطق العمل، مثل تحديث البيانات على الشاشة، يجب أن يعالجه ViewModel.

يوضّح المثال التالي كيفية استخدام أزرار مختلفة لتوسيع أحد عناصر واجهة المستخدم (منطق واجهة المستخدم) وتحديث البيانات على الشاشة (منطق العمل):

@Composable
fun LatestNewsScreen(viewModel: LatestNewsViewModel = viewModel()) {

    // State of whether more details should be shown
    var expanded by remember { mutableStateOf(false) }

    Column {
        Text("Some text")
        if (expanded) {
            Text("More details")
        }

        Button(
        // The expand details event is processed by the UI that
        // modifies this composable's internal state.
        onClick = { expanded = !expanded }
        ) {
        val expandText = if (expanded) "Collapse" else "Expand"
        Text("$expandText details")
        }

        // The refresh event is processed by the ViewModel that is in charge
        // of the UI's business logic.
        Button(onClick = { viewModel.refreshNews() }) {
            Text("Refresh data")
        }
    }
}

أحداث المستخدم في القوائم الكسولة

إذا تم إنتاج الإجراء في مكان أبعد في شجرة واجهة المستخدم، مثلاً في عنصر LazyColumn، يجب أن يظلّ ViewModel هو المسؤول عن معالجة أحداث المستخدم.

على سبيل المثال، لنفترض أنّ لديك قائمة بعناصر قابلة للنقر. لا تمرِّر مثيل ViewModel إلى العنصر المركّب للقائمة (MyList)، لأنّ ذلك يربط مكوّن واجهة المستخدم بإحكام بتفاصيل التنفيذ.

بدلاً من ذلك، اعرض الحدث كمعلَمة دالة لامدا في العنصر المركّب. يسمح ذلك للقائمة بتشغيل الحدث بدون معرفة من يعالجه أو كيف تتم معالجته.

data class MyItem(val id: Int)

@Composable
fun MyList(
    items: List<String>,
    onItemClick: (MyItem) -> Unit
) {
    Card {
        LazyColumn {
            itemsIndexed(items) { index, string ->
                ListItem(
                    modifier = Modifier.clickable {
                        onItemClick(MyItem(index))
                    },
                    headlineContent = {
                        Text(text = string)
                    }
                )
            }
        }
    }
}

في هذا النهج، لا يعمل العنصر المركّب MyList إلا مع البيانات التي يعرضها والأحداث التي يعرضها. وليس لديه إذن الوصول إلى ViewModel. يتم رفع الحدث وتمريره إلى ViewModel في عنصر مركّب سابق.

لمزيد من المعلومات عن معالجة الأحداث، يُرجى الاطّلاع على الأحداث في Compose.

اصطلاحات تسمية دوال أحداث المستخدم ومعالِجات الأحداث

في هذا الدليل، تتم تسمية دوال ViewModel التي تعالج أحداث المستخدم بفعل استنادًا إلى الإجراء الذي تعالجه، مثلاً: validateInput() أو login().

تتّبع معالِجات الأحداث في Compose اصطلاح تسمية معيّنًا لجعل تدفق البيانات واضحًا:

  • اسم المعلَمة: on + Verb + Target (مثلاً، onExpandClicked أو onValueChange).
  • تعبير لامدا: عند استدعاء العنصر المركّب، غالبًا ما يكون لامدا مجرد تنفيذ لهذا الحدث.

معالجة أحداث ViewModel

يجب أن تؤدي إجراءات واجهة المستخدم التي تنشأ من ViewModel، أي أحداث ViewModel، دائمًا إلى تعديل حالة واجهة المستخدم. يتوافق ذلك مع مبادئ تدفق البيانات أحادي الاتجاه Flow. ويجعل الأحداث قابلة للتكرار بعد تغييرات الإعدادات ويضمن عدم فقدان إجراءات واجهة المستخدم. يمكنك أيضًا اختياريًا جعل الأحداث قابلة للتكرار بعد إيقاف العملية نهائيًا إذا كنت تستخدم وحدة الحالة المحفوظة.

إنّ ربط إجراءات واجهة المستخدم بحالة واجهة المستخدم ليس عملية بسيطة دائمًا، ولكنّه يؤدي إلى منطق أبسط. يجب ألا تتوقف عملية التفكير عند تحديد كيفية انتقال واجهة المستخدم إلى شاشة معيّنة، مثلاً. عليك التفكير أكثر في كيفية تمثيل تدفق المستخدم هذا في حالة واجهة المستخدم. بعبارة أخرى: لا تفكّر في الإجراءات التي يجب أن تتخذها واجهة المستخدم، بل فكّر في كيفية تأثير هذه الإجراءات في حالة واجهة المستخدم.

على سبيل المثال، لنفترض أنّ لديك شاشة تسجيل الدخول. يمكنك تصميم حالة واجهة المستخدم لهذه الشاشة على النحو التالي:

data class LoginUiState(
    val isLoginInProgress: Boolean = false,
    val errorMessage: String? = null,
    val isUserLoggedIn: Boolean = false
)

تتفاعل شاشة تسجيل الدخول مع التغييرات في حالة واجهة المستخدم.

class LoginViewModel : ViewModel() {

    var uiState by mutableStateOf(LoginUiState())

    fun tryLogin(username: String, password: String) {
        viewModelScope.launch {
            // Emit a new state indicating that login is in progress
            uiState = uiState.copy(isLoginInProgress = true)

            uiState = if (login(username, password)) {
                // Emit a new state indicating that login was successful
                uiState.copy(isLoginInProgress = false, isUserLoggedIn = true)
            } else {
                // Emit a new state with the error message
                LoginUiState(isLoginInProgress = false, errorMessage = "Login failed")
            }
        }
    }

    private suspend fun login(username: String, password: String): Boolean {
        delay(1000)
        return (username == "Hello" && password == "World!")
    }
}

@Composable
fun LoginScreen(viewModel: LoginViewModel, onSuccessfulLogin: () -> Unit) {

    val uiState = viewModel.uiState

    LaunchedEffect(uiState) {
        if (uiState.isUserLoggedIn) {
            onSuccessfulLogin()
        }
    }

    if (uiState.isLoginInProgress) {
        CircularProgressIndicator()
    } else {
        LoginForm(
            onLoginAttempt = { username, password ->
                viewModel.tryLogin(username, password)
            },
            errorMessage = uiState.errorMessage
        )
    }
}

يمكن أن يؤدي استهلاك الأحداث إلى تعديلات في الحالة

قد يؤدي استهلاك أحداث معيّنة في ViewModel في واجهة المستخدم إلى تعديلات أخرى في حالة واجهة المستخدم. على سبيل المثال، عند عرض رسائل مؤقتة على الشاشة لإعلام المستخدم بحدوث أمر ما، تحتاج واجهة المستخدم إلى إشعار ViewModel لتشغيل تعديل آخر في الحالة بعد عرض الرسالة على الشاشة. يمكن التعامل مع الحدث الذي يحدث عندما يستهلك المستخدم الرسالة (من خلال إغلاقها أو بعد انتهاء المهلة) على أنّه "بيانات أدخلها المستخدم"، وبالتالي يجب أن يكون ViewModel على علم بذلك. في هذه الحالة، يمكن تصميم حالة واجهة المستخدم على النحو التالي:

// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
    val news: List<News> = emptyList(),
    val isLoading: Boolean = false,
    val userMessage: String? = null
)

سيعدّل ViewModel حالة واجهة المستخدم على النحو التالي عندما يتطلب منطق العمل عرض رسالة مؤقتة جديدة للمستخدم:

class LatestNewsViewModel(/* ... */) : ViewModel() {

    var uiState by mutableStateOf(LatestNewsUiState())
        private set

    fun refreshNews() {
        viewModelScope.launch {
            // If there isn't internet connection, show a new message on the screen.
            if (!internetConnection()) {
                uiState = uiState.copy(userMessage = "No Internet connection")
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown() {
        uiState = uiState.copy(userMessage = null)
    }
}

لا يحتاج ViewModel إلى معرفة كيفية عرض واجهة المستخدم للرسالة على الشاشة، بل يعرف فقط أنّه يجب عرض رسالة للمستخدم. بعد عرض الرسالة المؤقتة، تحتاج واجهة المستخدم إلى إشعار ViewModel بذلك، ما يؤدي إلى تعديل آخر في حالة واجهة المستخدم لمحو السمة userMessage:

@Composable
fun LatestNewsScreen(
    snackbarHostState: SnackbarHostState,
    viewModel: LatestNewsViewModel = viewModel(),
) {
    // Rest of the UI content.

    // If there are user messages to show on the screen,
    // show it and notify the ViewModel.
    viewModel.uiState.userMessage?.let { userMessage ->
        LaunchedEffect(userMessage) {
            snackbarHostState.showSnackbar(userMessage)
            // Once the message is displayed and dismissed, notify the ViewModel.
            viewModel.userMessageShown()
        }
    }
}

على الرغم من أنّ الرسالة مؤقتة، فإنّ حالة واجهة المستخدم تمثّل بدقة ما يتم عرضه على الشاشة في كل نقطة زمنية. إما أن يتم عرض رسالة المستخدم أو لا يتم عرضها.

يوضّح قسم يمكن أن يؤدي استهلاك الأحداث إلى تعديلات في الحالة كيفية استخدام حالة واجهة المستخدم لعرض رسائل المستخدمين على الشاشة. أحداث التنقّل هي أيضًا نوع شائع من الأحداث في تطبيق Android.

إذا تم تشغيل الحدث في واجهة المستخدم لأنّ المستخدم نقر على زر، تتولى واجهة المستخدم ذلك من خلال عرض الحدث على العنصر المركّب الذي يستدعيها.

@Composable
fun LoginScreen(
    onHelp: () -> Unit, // Caller navigates to the help screen
    viewModel: LoginViewModel = viewModel()
) {
    // Rest of the UI
    Button(
        onClick = dropUnlessResumed { onHelp() }
    ) {
        Text("Get help")
    }
}

dropUnlessResumed جزء من مكتبة مراحل النشاط ويسمح لك بتشغيل الدالة onHelp فقط عندما تكون مرحلة النشاط على الأقل RESUMED.

إذا كان إدخال البيانات يتطلب بعض التحقّق من منطق العمل قبل التنقّل، سيحتاج ViewModel إلى عرض هذه الحالة على واجهة المستخدم. ستتفاعل واجهة المستخدم مع تغيير الحالة هذا وتنتقل وفقًا لذلك. يغطّي قسم معالجة أحداث ViewModel حالة الاستخدام هذه. في ما يلي رمز مشابه:

@Composable
fun LoginScreen(
    onUserLogIn: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    Button(
        onClick = {
            // ViewModel validation is triggered
            viewModel.tryLogin()
        }
    ) {
        Text("Log in")
    }
    // Rest of the UI

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)
    LaunchedEffect(viewModel, lifecycle)  {
        // Whenever the uiState changes, check if the user is logged in and
        // call the `onUserLogin` event when `lifecycle` is at least STARTED
        snapshotFlow { viewModel.uiState }
            .filter { it.isUserLoggedIn }
            .flowWithLifecycle(lifecycle)
            .collect {
                currentOnUserLogIn()
            }
    }
}

في المثال أعلاه، يعمل التطبيق على النحو المتوقّع لأنّه لن يتم الاحتفاظ بالوجهة الحالية، وهي "تسجيل الدخول"، في مكدس الرجوع. لا يمكن للمستخدمين الرجوع إليها إذا ضغطوا على زر الرجوع. ومع ذلك، في الحالات التي قد يحدث فيها ذلك، سيتطلب الحلّ منطقًا إضافيًا.

عندما يضبط ViewModel حالة معيّنة تؤدي إلى حدث تنقّل من الشاشة "أ" إلى الشاشة "ب" ويتم الاحتفاظ بالشاشة "أ" في مكدس الرجوع للتنقّل، قد تحتاج إلى منطق إضافي لعدم الانتقال تلقائيًا إلى الشاشة "ب". لتنفيذ ذلك، تحتاج إلى الحالة إضافية للإشارة إلى ما إذا كان على واجهة المستخدم الانتقال إلى الشاشة الأخرى. عادةً ما يتم الاحتفاظ بهذه الحالة في واجهة المستخدم لأنّ منطق التنقّل هو من مسؤوليات واجهة المستخدم، وليس ViewModel. لتوضيح ذلك، لنفترض حالة الاستخدام التالية.

لنفترض أنّك في عملية التسجيل في تطبيقك. في شاشة التحقّق من صحة تاريخ الميلاد، عندما يُدخِل المستخدم تاريخًا، يتحقّق ViewModel من صحة التاريخ عندما ينقر المستخدم على الزر "متابعة". يفوّض ViewModel منطق التحقّق من الصحة إلى طبقة البيانات. إذا كان التاريخ صالحًا، ينتقل المستخدم إلى الشاشة التالية. كميزة إضافية، يمكن للمستخدمين الرجوع إلى شاشات التسجيل المختلفة والعودة منها إذا أرادوا تغيير بعض البيانات. لذلك، يتم الاحتفاظ بجميع الوجهات في عملية التسجيل في مكدس الأنشطة السابقة نفسه. بالنظر إلى هذه المتطلبات، يمكنك تنفيذ هذه الشاشة على النحو التالي:

class DobValidationViewModel(/* ... */) : ViewModel() {
    var uiState by mutableStateOf(DobValidationUiState())
        private set
}

@Composable
fun DobValidationScreen(
    onNavigateToNextScreen: () -> Unit, // Caller navigates to the right screen
    viewModel: DobValidationViewModel = viewModel()
) {
    // TextField that updates the ViewModel when a date of birth is selected

    var validationInProgress by rememberSaveable { mutableStateOf(false) }

    Button(
        onClick = {
            viewModel.validateInput()
            validationInProgress = true
        }
    ) {
        Text("Continue")
    }
    // Rest of the UI

    /*
        * The following code implements the requirement of advancing automatically
        * to the next screen when a valid date of birth has been introduced
        * and the user wanted to continue with the registration process.
        */

    if (validationInProgress) {
        val lifecycle = LocalLifecycleOwner.current.lifecycle
        val currentNavigateToNextScreen by rememberUpdatedState(onNavigateToNextScreen)
        LaunchedEffect(viewModel, lifecycle) {
            // If the date of birth is valid and the validation is in progress,
            // navigate to the next screen when `lifecycle` is at least STARTED,
            // which is the default Lifecycle.State for the `flowWithLifecycle` operator.
            snapshotFlow { viewModel.uiState }
                .filter { it.isDobValid }
                .flowWithLifecycle(lifecycle)
                .collect {
                    validationInProgress = false
                    currentNavigateToNextScreen()
                }
        }
    }
}

إنّ التحقّق من صحة تاريخ الميلاد هو منطق عمل مسؤول عنه ViewModel. في معظم الأحيان، يفوّض ViewModel هذا المنطق إلى طبقة البيانات. إنّ منطق توجيه المستخدم إلى الشاشة التالية هو منطق واجهة المستخدم لأنّ هذه المتطلبات قد تتغيّر استنادًا إلى إعدادات واجهة المستخدم. على سبيل المثال، قد لا تريد الانتقال تلقائيًا إلى شاشة أخرى على جهاز لوحي إذا كنت تعرض خطوات تسجيل متعدّدة في الوقت نفسه. تنفّذ السمة validationInProgress في الرمز أعلاه هذه الوظيفة وتحدّد ما إذا كان على واجهة المستخدم الانتقال تلقائيًا عندما يكون تاريخ الميلاد صالحًا ويريد المستخدم المتابعة إلى خطوة التسجيل التالية أم لا.

حالات استخدام أخرى

إذا كنت تعتقد أنّه لا يمكن حلّ حالة استخدام حدث واجهة المستخدم من خلال تعديلات حالة واجهة المستخدم، قد تحتاج إلى إعادة النظر في كيفية تدفق البيانات في تطبيقك. لنأخذ في الاعتبار المبادئ التالية:

  • يجب أن تقوم كل فئة بما هي مسؤولة عنه، وليس أكثر. واجهة المستخدم مسؤولة عن منطق سلوك الشاشة، مثل طلبات التنقّل وأحداث النقر وطلبات الحصول على الأذونات. يحتوي ViewModel على منطق العمل ويحوّل النتائج من الطبقات السفلية من التسلسل الهرمي إلى حالة واجهة المستخدم.
  • فكّر في مصدر الحدث. اتّبِع شجرة القرارات المعروضة في بداية هذا الدليل، واجعل كل فئة تعالج ما هي مسؤولة عنه. على سبيل المثال، إذا كان مصدر الحدث هو واجهة المستخدم وأدى إلى حدث تنقّل، يجب معالجة هذا الحدث في واجهة المستخدم. قد يتم تفويض بعض المنطق إلى ViewModel، ولكن لا يمكن تفويض معالجة الحدث بالكامل إلى ViewModel.
  • إذا كان لديك مستهلكون متعدّدون وكنت قلقًا بشأن استهلاك الحدث عدة مرات، قد تحتاج إلى إعادة النظر في بنية تطبيقك. يؤدي وجود مستهلكين متعدّدين في الوقت نفسه إلى صعوبة بالغة في ضمان العقد تم التسليم مرة واحدة بالضبط، لذا يزداد مقدار التعقيد والسلوك الدقيق بشكل كبير. إذا كنت تواجه هذه المشكلة، ننصحك بنقل هذه المشاكل إلى أعلى في شجرة واجهة المستخدم، وقد تحتاج إلى كيان مختلف ضمن نطاق أعلى في التسلسل الهرمي.
  • فكّر في الوقت الذي يجب فيه استهلاك الحالة. في حالات معيّنة، قد لا تريد مواصلة استهلاك الحالة عندما يكون التطبيق في الخلفية، مثلاً، عرض Toast. في هذه الحالات، ننصحك باستهلاك الحالة عندما تكون واجهة المستخدم في المقدّمة.

نماذج

توضّح نماذج Google التالية أحداث واجهة المستخدم في طبقة واجهة المستخدم. ننصحك باستكشافها للاطّلاع على هذا التوجيه عمليًا:

مراجع إضافية

لمزيد من المعلومات عن أحداث واجهة المستخدم، يُرجى الاطّلاع على المراجع الإضافية التالية:

اختبارات الرموز

الوثائق

عرض المحتوى