В прошлом уроке мы закончили основной функционал андроид приложения “Книжная полка”. В этом уроке добавим в приложение панель поиска 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 ) } ) } } } }
Запускаем приложение и проверяем работу панели поиска и перехода на страницу описания.
При нажатии иконки поиска панель переходит в состояние OPENED и становится доступна для ввода текста. Вводим текст для поиска книги, и подгружается список по соответствующему запросу. При клике на обложке можно перейти на страницу с описанием книги, здесь многие книги также доступны для чтения.
На этом урок закончен, исходники можно скачать по ссылке.