Как создать мобильное приложение на Kotlin с Jetpack Compose и Clean Architecture. Часть 3. Панель поиска Compose

В прошлом уроке мы закончили основной функционал андроид приложения “Книжная полка”. В этом уроке добавим в приложение панель поиска Compose с возможностью получения списка книг с удаленного сервера по поисковому запросу, текст которого пользователь будет вводить в панель поиска. А также добавим возможность при клике на обложке книги в списке открывать в браузере страницу информации об этой книге.

Панель поиска Compose

Наша панель поиска будет иметь состояния открыта, когда можно ввести текст для поиска, и закрыта – состояние после закрытия и по умолчанию, идем в клас BooksViewModel и создадим здесь класс-перечисления для этих состояний:

enum class SearchWidgetState {
    OPENED,
    CLOSED
}

Добавим поля для хранения состояний панели поиска, а также введенного текста. Для каждого состояния будет две переменные – приватная типа MutableState (это изменяемый класс для хранения состояния) и публичная переменная типа State для чтения свойства извне, которой мы будем присваивать значения приватного поля.

private val _searchWidgetState: MutableState<SearchWidgetState> =
    mutableStateOf(value = SearchWidgetState.CLOSED)
val searchWidgetState: State<SearchWidgetState> = _searchWidgetState

private val _searchTextState: MutableState<String> =
    mutableStateOf(value = "")
val searchTextState: State<String> = _searchTextState

Поле searchWidgetState будет хранить два типа состояния панели – открыта / закрыта.

Поле searchTextState будет хранить текст для поиска.

Добавим также две функци: updateSearchWidgetState для изменения состояния панели поиска, для сохранения нового значения в приватную переменную.

fun updateSearchWidgetState(newValue: SearchWidgetState) {
    _searchWidgetState.value = newValue
}

fun updateSearchTextState(newValue: String) {
    _searchTextState.value = newValue
}

Вторая функция updateSearchTextState для обновления состояния текстового поля.

Теперь напишем панель поиска, создаем в папке ui файл SearchBar. Поскольку панель поиска имеет два состояния, напишем две Composable функции – ClosedAppBar и OpenedAppBar.

@Composable
fun ClosedAppBar(onSearchClicked: () -> Unit) {
    TopAppBar(
        title = {
            Text(
                text = stringResource(id = R.string.app_name)
            )
        },
        actions = {
            IconButton(
                onClick = { onSearchClicked() }
            ) {
                Icon(
                    imageVector = Icons.Filled.Search,
                    contentDescription = "SearchIcon",
                    tint = Color.White
                )

            }
        }
    )
}

Компонент ClosedAppBar принимает функцию onSearchClicked, которая будет вызываться при клике на иконке поиска в панели.

Внутри нашего компонента разместим стандартный компонент верхней панели TopAppBar. Его параметру title мы передадим текстовый компонент, отображающий название приложения.

Параметр actions будет принимать компонент IconButton с параметром onClick, куда мы передадим функцию. В теле IconButton разместим компонент Icon и зададим ему иконку поиска из стандартного набора, ее описание и цвет. Цвет устанавливаем белый.

Также создадим компонент OpenedAppBar, которому будем передавать текст поискового запроса, и три функции – onTextChange, onCloseClicked, и onSearchClicked.

@Composable
fun OpenedAppBar(
    text: String,
    onTextChange: (String) -> Unit,
    onCloseClicked: () -> Unit,
    onSearchClicked: (String) -> Unit,
) {
    Surface(
        modifier = Modifier
            .fillMaxWidth()
            .height(56.dp),
        elevation = AppBarDefaults.TopAppBarElevation,
        color = MaterialTheme.colors.primary
    ) {
        TextField(
            modifier = Modifier
                .fillMaxWidth(),
            value = text,
            onValueChange = {
                onTextChange(it)
            },
            placeholder = {
                Text(
                    modifier = Modifier
                        .alpha(ContentAlpha.medium),
                    text = "Search here...",
                    color = Color.White
                )
            },
            textStyle = TextStyle(
                fontSize = MaterialTheme.typography.subtitle1.fontSize
            ),
            singleLine = true,
            leadingIcon = {
                IconButton(
                    modifier = Modifier
                        .alpha(ContentAlpha.medium),
                    onClick = { onSearchClicked(text) }
                ) {
                    Icon(
                        imageVector = Icons.Default.Search,
                        contentDescription = "Search Icon",
                        tint = Color.White
                    )

                }
            },
            trailingIcon = {
                IconButton(
                    onClick = {
                        if (text.isNotEmpty()) {
                            onTextChange("")
                        } else {
                            onCloseClicked()
                        }
                    }
                ) {
                    Icon(
                        imageVector = Icons.Default.Close,
                        contentDescription = "Close Icon",
                        tint = Color.White
                    )

                }
            },
            keyboardOptions = KeyboardOptions(
                imeAction = ImeAction.Search
            ),
            keyboardActions = KeyboardActions(
                onSearch = {
                    onSearchClicked(text)
                }
            ),
            colors = TextFieldDefaults.textFieldColors(
                backgroundColor = Color.Transparent,
                cursorColor = Color.White.copy(alpha = ContentAlpha.medium)
            )
            )
    }
}

Внутри добавляем компонент Surface, задаем ему ширину по продителю и стандартную высоту тулбара – 56 dp, дефолтное поднятие TopAppBarElevation, а также добавим цвет – это будет основной цвет приложения. Внутри Surface добавляем компонент TextField, которому предаем текст, а а параметру onValueChange передаем функцию onTextChange, которая будет получать текст, введенный пользователем.

В параметр placeholder передадим компонет Text, который будет отображаться как хинт в строке ввода запроса. С помощью модификатора сделаем его полупрозрачным, зададим отображаемый текст, а цвет установим белый.

Далее зададим стиль текста – подзаголовок первого размера.

Свойство singleLine ограничивает отображения введенного текста максимум в одну строку.

Параметр leadingIcon отображает иконку в начале текстового поля. От будет содержать компонент IconButton, через модификатор зададим ему полупрозрачность. Свойство onClick будет вызывать функцию поиска onSearchClicked с передачей в нее текста.

Мы ему передадим все ту же иконку поиска, при нажатии которой будет выполняться поиск.

Параметр trailingIcon отображает иконку в конце текстового поля. Ему мы будем передавать IconButton – иконку закрытия панели поиска, при клике на которой, если текстовое поле содержит текст, он будет удаляться, а если поле пустое – вызываем функцию onCloseClicked(), которая будет переводить панель поиска в закрытое состояние. Установим для IconButton стандартную иконку Close – крестик.

Параметр keyboardOptions принимает одноименный класс, позволяющий менять параметр стандартной клавиатуры, здесь мы указываем значение параметра imeAction – передаем объект ImeAction.Search, задающий стандартной клавиатуре тип кнопки ввода – это будет кнопка поиска.

Параметр keyboardActions определяет действие при нажатии кнопки поиска на стандартной клавиатуре – будем вызывать функцию onSearchClicked и передавать ей текст для поиска.

Наконец, с помощью параметра colors зададим фоновый цвет панели поиска – сделаем ее прозрачной. Также зададим цвет курсора – он будет белым, а с помощью метода copy изменим свойство alpha цвета, сделаем его наполовину прозрачным.

Теперь напишем общую функцию MainAppBar, которая принимает состояние панели SearchWidgetState, состояние строки поиска – текст searchTextState, а также функцию onTextChange для сохранения текстового запроса в переменную состояния во ViewModel, функцию onCloseClicked для обработки нажатия кнопки закрытия, функцию onSearchClicked для обработки нажатия кнопки поиска и функцию onSearchTriggered для открытия панели поиска по первому щелчку на значке поиска.

@Composable
fun MainAppBar(
    searchWidgetState: SearchWidgetState,
    searchTextState: String,
    onTextChange: (String) -> Unit,
    onCloseClicked: () -> Unit,
    onSearchClicked: (String) -> Unit,
    onSearchTriggered: () -> Unit
) {
    when (searchWidgetState) {
        SearchWidgetState.CLOSED -> {
            ClosedAppBar (
                onSearchClicked = onSearchTriggered
            )
        }
        SearchWidgetState.OPENED -> {
            OpenedAppBar(
                text = searchTextState,
                onTextChange = onTextChange,
                onCloseClicked = onCloseClicked,
                onSearchClicked = onSearchClicked
            )
        }
    }
}

В MainAppBar пишем условный оператор when, и в зависимости от полученного из ViewModel состояния будем отображать или ClosedAppBar и передавать функцию onSearchTriggered, или OpenedAppBar с передачей поискового текста, состояния текстового поля и функций обработки нажатий иконок закрытия и поиска.

Мы закончили написание панели поиска, теперь нужно добавить ее вместо стандартного тулбара. Открываем файл BooksApp и добавим инициализацию переменных для получения состояний панели поиска и текстового поля из BooksViewModel.

Далее внутри компонента Scaffold в его параметр topBar передаем наш MainAppBar вместо стандартного топбара. В его параметры передаем состояния из BooksViewModel, и вызываем функции обновления состояния текстового поля, и функции обработки нажатий на значки панели поиска. Внутри мы вызываем соответствующие функции BooksViewModel. В onCloseClicked передаем состояние CLOSED, в onSearchClicked вызываем функцию getBooks с передаем ей текстовый запрос для поиска, а в onSearchTriggered передаем состоние OPENED.

Открытие книги

Панель поиска мы закончили, осталось реализовать переход по клику на обложке на страницу описания книги. Реализация этого будет очень проста – мы будем создавать Intent.ACTION_VIEW и передавать ему ссылку страницы описания из объекта Book.

Нажатие на элемент списка обрабатывается в composable функции BooksCard, отвечающей за отображение книги в списке. Мы можем вызывать интент прямо здесь, но это не будет правильным подходом, поскольку внутри UI не рекомендуется хранить состояния и обрабатывать какую- либо логику. Composable компоненты по возможности должны быть максимально stateless, а состояния и действия должны пробрасываться максимально наверх, к общему предку – этот подход называется подъемом состояния. Хорошим местом для хранения состояний и обработки логики является ViewModel, но для старта интента нам нужен контекст, а контекст не рекомендуется хранить во ViewModel – это вызовет утечку памяти. Чтобы не усложнять наше приложение, можно вызвать интент внутри активити, используя его контекст. Поскольку родительский composable компонент вызывается в активити передадим туда по цепочке нашу функцию обработки нажатия.

В блоке параметров компонента BooksCard дописываем функцию onBookClicked, принимающую в качестве параметра экземпляр типа Book. Далее в компоненте Card у модификатора вызываем метод clicable, и в параметр onClick передаем вызов функции onBookClicked, принимающей экземпляр book.

@Composable
fun BooksCard(
    book: Book,
    modifier: Modifier,
    onBookClicked: (Book) -> Unit
) {
    Card(
        modifier = modifier
            .padding(4.dp)
            .fillMaxWidth()
            .requiredHeight(296.dp)
            .clickable { onBookClicked(book) },
        elevation = 8.dp
    ) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            book.title?.let {
                Text(
                    text = it,
                    textAlign = TextAlign.Center,
                    modifier = modifier
                        .padding(top = 4.dp, bottom = 8.dp)
                )
            }
            AsyncImage(
                modifier = modifier.fillMaxWidth(),
                model = ImageRequest.Builder(context = LocalContext.current)
                    .data(book.imageLink?.replace("http:", "https:"))
                    .crossfade(true)
                    .build(),
                error = painterResource(id = R.drawable.ic_book_96),
                placeholder = painterResource(id = R.drawable.loading_img),
                contentDescription = stringResource(id = R.string.content_description),
                contentScale = ContentScale.Crop
            )
        }
    }
}

После этих изменений у нас появится ошибка компиляции в месте вызова компонента BooksCard – внутри компонента BooksGridScreen, поскольку мы дописали новый параметр, и его необходимо инициализировать. Добавим аналогичный параметр – функцию onBookClicked в BooksGridScreen и передадим ее в BooksCard.

@Composable
fun BooksGridScreen(
    books: List<Book>,
    modifier: Modifier,
    onBookClicked: (Book) -> Unit
) {
    LazyVerticalGrid(
        columns = GridCells.Adaptive(150.dp), 
        contentPadding = PaddingValues(4.dp)
    ) {
        itemsIndexed(books) { _, book -> 
            BooksCard(book = book, modifier, onBookClicked)
        }
    }
}

Аналогично действуем вверх по всей цепочке вызова компонентов UI, до самого активити.

HomeScreen:

@Composable
fun HomeScreen(
    booksUiState: BooksUiState,
    retryAction: () -> Unit,
    modifier: Modifier,
    onBookClicked: (Book) -> Unit
) {
    when (booksUiState) {
        is BooksUiState.Loading -> LoadingScreen(modifier)
        is BooksUiState.Success -> BooksGridScreen(
            books = booksUiState.bookSearch,
            modifier = modifier,
            onBookClicked
        )
        is BooksUiState.Error -> ErrorScreen(retryAction = retryAction, modifier)
    }
}

Также в BooksApp:

@Composable
fun BooksApp(
    modifier: Modifier = Modifier,
    onBookClicked: (Book) -> Unit
) {
    val booksViewModel: BooksViewModel =
        viewModel(factory = BooksViewModel.Factory)
    val searchWidgetState = booksViewModel.searchWidgetState
    val searchTextState = booksViewModel.searchTextState

    Scaffold(
        modifier = modifier.fillMaxSize(),
        topBar = {
            MainAppBar(
                searchWidgetState = searchWidgetState.value,
                searchTextState = searchTextState.value,
                onTextChange = {
                               booksViewModel.updateSearchTextState(newValue = it)
                },
                onCloseClicked = {
                                 booksViewModel.updateSearchWidgetState(newValue = SearchWidgetState.CLOSED)
                },
                onSearchClicked = {
                    booksViewModel.getBooks(it)
                },
                onSearchTriggered = {
                    booksViewModel.updateSearchWidgetState(newValue = SearchWidgetState.OPENED)
                }
            )
        }
    ) {
        Surface(modifier = modifier
            .fillMaxSize()
            .padding(it),
            color = MaterialTheme.colors.background
        ) {
            HomeScreen(
                booksUiState = booksViewModel.booksUiState,
                retryAction = { booksViewModel.getBooks() },
                modifier = modifier,
                onBookClicked
            )
        }
    }
}

В MainActivity в параметре onBookClicked пишем функцию, где вызываем ContextCompat.startActivity, передаем ему контекст и наш интент для открытия ссылки описания книги в браузере.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BookShelfTheme {
                BooksApp(
                    onBookClicked = {
                        ContextCompat.startActivity(
                            this,
                            Intent(Intent.ACTION_VIEW, Uri.parse(it.previewLink)),
                            null
                        )
                    }
                )
            }
        }
    }
}

Запускаем приложение и проверяем работу панели поиска и перехода на страницу описания.

Как создать мобильное приложение на Kotlin с Jetpack Compose и Clean Architecture. Часть 3. Панель поиска Compose

При нажатии иконки поиска панель переходит в состояние OPENED и становится доступна для ввода текста. Вводим текст для поиска книги, и подгружается список по соответствующему запросу. При клике на обложке можно перейти на страницу с описанием книги, здесь многие книги также доступны для чтения.

На этом урок закончен, исходники можно скачать по ссылке.

Додати коментар

Цей сайт використовує Akismet для зменшення спаму. Дізнайтеся, як обробляються ваші дані коментарів.