Как создать мобильное приложение на 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 и становится доступна для ввода текста. Вводим текст для поиска книги, и подгружается список по соответствующему запросу. При клике на обложке можно перейти на страницу с описанием книги, здесь многие книги также доступны для чтения.

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

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