One UI существует уже как 3 года (с 2018), а уроков по тому, как сделать похожий дизайн, в мире android, я так и не нашёл. Не порядок… Сегодня же, мы начнём прокладывать этот тяжёлый и тернистый путь.
Зарождение Siesta
Я думаю, разделить статьи на версии нашего домашнего One UI. Благодаря этому, я буду иметь уникальную возможность общаться и отвечать на интересующие вас вопросы. Возможно, вы знаете более оптимизированный и лучший способ реализовать что-либо и в следующей статье (версии нашего One UI - Siesta) мы можем это применить.
В результате всех наших страданий мы получим вот такое приложение на выходе. Да, это не копия Samsung оболочки (прям совсем), но наша цель – унаследовать лишь идею использования одной рукой, а не скопировать шрифты и иконки…
Идея One UI
Давайте взглянем на стандартное приложение настроек в OneUI.
Весь экран можно разделить на зоны, где 1/3 экрана занимает огромный текст, показывающий на какой вкладке настроек мы находимся. Благодаря ему пользователи не должны тянуться на верхние края экрана. Затем идёт 2-ая зона, назовём её “панель управления”, эта зона прокручивается пока не достигнет верхнего края экрана, после чего будет примагничена, дабы не улететь за его края. В последнюю зону входит весь остальной контент, с которым будет взаимодействовать пользователь.
Это и есть главная идея OneUI которую мы должны повторить.
Создание и настройка проекта
Начнём с создания нового проекта, укажем минимальные требования Android 8.0, так как начиная с данной версии в TextView можно присвоить AutoSize параметр. Если вам необходимо работать с более ранними версиями Android – не беда. Существует библиотека поддержки в таких случаях.
Распространение Android
Frontend - создание зон
Итак, перед нами пустое Activity. Родительским элементом будет являться RelativeLayout, потому что нам необходимо поставить “панель управления” на ScrollView. После чего создаём сам ScrollView. В ScrollView может находится лишь один дочерний элемент, этим элементом будет являться LinearLayout, т.к. он позволяет распределять элементы последовательно. В примере я вставил часть кода, чтобы вы понимали наглядно о чём идёт речь.
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<!--место 2 зоны-->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="50dp">
</RelativeLayout>
<!--здесь будут храниться 1 и 3 зона-->
<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/scrollLinearLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
</LinearLayout>
</ScrollView>
</RelativeLayout>
1 зона - theBigText
Теперь необходимо создать тот самый большой текст. Дадим ему id, строгую высоту в 250dp, установим параметр autoSizeTextType=”uniform” для автоматического изменения размера, gravity=”center” для центрирования, пару padding’ов для красоты и жирный шрифт.
<TextView
android:id="@+id/theBigText"
android:layout_width="match_parent"
android:layout_height="250dp"
android:autoSizeTextType="uniform"
android:gravity="center"
android:paddingHorizontal="50dp"
android:paddingTop="100dp"
android:paddingBottom="30dp"
android:text="Настройки"
android:textStyle="bold"/>
2 зона - "панель управления"
Теперь изменим нашу панель управления.
Добавим id, установим строгую высоту в 50dp, orientation=”horizontal” для правильного отображения элементов и layout_marginTop=”250dp” (в размер нашего главного текста, чтобы быть ровно под ним). Мы не можем установить атрибуты, обращающиеся напрямую к theBigText, т.к. он является дочерним элементом ScrollView, поэтому приходится ставить строгие значения. Заполним нашу “панель управления”. Вставим в неё TextView и установим для неё атрибуты: gravity=”center_vertical”, textSize=”30sp” и alpha=”0” (ведь текст должен быть виден только, когда панель прокручена вверх)
ImageView: установим строгий размер в 40dp, gravity=”center_vertical”, alpha=”0.7”, установим картинку компаса и присоединим к правой части экрана.
Опять же, все наглядные примеры находятся ниже.
<RelativeLayout
android:id="@+id/panel"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="250dp"
android:background="@color/white"
android:orientation="horizontal">
<TextView
android:id="@+id/panelText"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:paddingStart="20dp"
android:text="настройки"
android:textSize="30sp"
android:alpha="0" />
<ImageView
android:id="@+id/compassIcon"
android:layout_width="40dp"
android:layout_height="match_parent"
android:layout_alignParentEnd="true"
android:layout_marginEnd="20dp"
android:gravity="center_vertical"
android:src="@drawable/compas"
android:alpha="0.7" />
</RelativeLayout>
Теперь мы столкнулись с проблемой. Наша “панель инструментов” занимает место, а если мы добавим любой элемент в ScrollView, то они будут пересекаться, поэтому, мы добавим пустой View, который будет съедать нужное нам под 2-ую зону место.
<View
android:layout_width="match_parent"
android:layout_height="80dp" />
3 зона - контент
Фронтенд главного Activity завершён. Теперь, давайте перейдём к заполнению 3-й зоны. Зоны контента. На 1-м скриншоте, можно было заметить, что зона контента состоит из “скруглённых прямоугольников”, давайте их повторим.
Для этого создадим отдельный LayoutResource под именем “block”. Он не будет иметь бэкенд, лишь xml файл.
CardView подойдёт под наш блок как никто лучше! Дадим нужные параметры CardView и заполним его картинкой и текстом. Все параметры схожи с предыдущими, поэтому повторно объяснять их не вижу смысла.
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView android:id="@+id/cardView"
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_margin="8dp"
app:cardCornerRadius="20dp"
app:cardElevation="20dp"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:weightSum="3">
<ImageView
android:id="@+id/image"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_gravity="center"
android:layout_marginVertical="20dp"
android:layout_marginStart="10dp"
android:alpha="0.7" />
<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="10dp"
android:textSize="22sp"
android:textStyle="bold"
android:alpha="0.7"
android:gravity="left|center_vertical" />
</LinearLayout>
</androidx.cardview.widget.CardView>
С фронтендом покончено, теперь можно переходить к самому интересному!
Backend
Перед setContentView я предлагаю внедрить несколько параметров. Все комментарии переносятся в код.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportActionBar?.hide()//Убирает верхний бар с названием приложения
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)//Ставит светлую тему по умолчанию
window.navigationBarColor = resources.getColor(R.color.black)//Ставит чёрный цвет навигационной панели
setContentView(R.layout.activity_main)
}
}
Теперь же, нужно создать пару глобальных переменных
private lateinit var scrollView: ScrollView
private lateinit var panel: RelativeLayout
private lateinit var panelText: TextView
private lateinit var theBigText: TextView
private lateinit var compassIcon: ImageView
private lateinit var scrollLinearLayout: LinearLayout
После setContentView инициализируем наши переменные
scrollView = findViewById(R.id.scrollView)
panel = findViewById(R.id.panel)
panelText = findViewById(R.id.theBigText)
theBigText = findViewById(R.id.panelText)
compassIcon = findViewById(R.id.compassIcon)
scrollLinearLayout = findViewById(R.id.scrollLinearLayout)
Теперь, мы должны получить MarginTop нашей “панели управления” в пикселях, т.к. отслеживать теперь мы будем только их. Чтобы это сделать добавляем в глобальные переменные maxScroll.
private var maxScroll = 0
private var scrollY = 0
И находим сам отступ
val params =
panel.layoutParams as ViewGroup.MarginLayoutParams//Высчитывает максимально возможный скролл для огромного текста
maxScroll = params.topMargin
Давайте заполним 3 зону контентом.
Для этого создадим функцию addCardToScroll. Все комментарии переехали в код, если вдруг что-то непонятно, отвечу в комментариях.
private fun addCardToScroll(_input: String) {
val blockView = View.inflate(this, R.layout.block, null)//Создаём 1 block
val blockText = blockView.findViewById<TextView>(R.id.text)//Инициализируем поле Text
val blockImage = blockView.findViewById<ImageView>(R.id.image)//Инициализируем поле Image
var isCheck = false
blockText.text = _input
blockImage.setImageResource(R.color.white)
scrollLinearLayout.addView(blockView)//Добавляем block в scrollView
val params =
blockView.layoutParams as? ViewGroup.MarginLayoutParams
params?.setMargins(20, 12, 20, 12)//Устанавливаем Margin
blockView.layoutParams = params//Присваиваем новые параметры
blockView.elevation = 20f//Поднимаем карточку вверх для появления тени вокруг
blockText.setAutoSizeTextTypeUniformWithConfiguration(
1, 22, 1, TypedValue.COMPLEX_UNIT_DIP)//С помощью кода устанавливаем атрибут AutoSize
//Присваиваем слушатель
blockView.setOnClickListener {
isCheck = !isCheck
if (isCheck)
blockImage.setImageResource(R.drawable.checkyes)//Заменяем иконку
else
blockImage.setImageResource(R.drawable.checkno)
animateView(blockImage)//Анимируем иконку
}
}
//Анимация иконок
private fun animateView(view: ImageView) {
when (val drawable = view.drawable) {
is AnimatedVectorDrawable -> {
drawable.start()
}
}
}
Мои картинки – анимации xml. Это позволяет добавить жизни в наше приложение. Вы же, можете заменить их на любую статичную картинку. Весь мой код, включая анимационные картинки, вы сможете найти на GitHub.
Уже хочется проверить, как работает наше приложение, не правда ли?
Для этого добавим в конце нашего onCreate() такие строчки, чтобы заполнить 3 зону контентом и запустим наше приложение.
for(index in 0..10){
addCardToScroll(index.toString())
}
Оживляем "панель управления"
Что же, приложение работает, анимации – тоже. Хорошо. А вот наша “панель управления” стоит на своём и никуда не двигается – логично, мы ведь и не прописали в каких случаях она должна двигаться. Но сначала, я бы изменил цвета нашего статус бара. Для этого, необходимо зайти в value->colors и изменить цвета. Можете использовать мой “элегантный” набор.
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#000000</color>
<color name="purple_500">#4E4E4E</color>
<color name="purple_700">#505050</color>
<color name="teal_200">#717171</color>
<color name="teal_700">#515151</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
Теперь перейдём к “панели управления“.
Наша цель -> узнать когда был произведён скролл. Повесить своего рода слушатель на ScrollView. Это делается благодаря этим строчкам.
scrollView.viewTreeObserver.addOnScrollChangedListener(ViewTreeObserver.OnScrollChangedListener { //Включается когда производится скролл
scrollEngine()
})
scrollEngine() – функция двигающая нашу “панель инструментов”, давайте напишем её.
private fun scrollEngine() {
val upDown: Boolean = scrollView.scrollY < scrollY// true if scroll up
val params =
panel.layoutParams as ViewGroup.MarginLayoutParams//Параметры LinearLayout MiniMain
val temp: Int = if (upDown) {//Считаем на сколько нужно подвинуть нашу panel
if (scrollView.scrollY <= maxScroll) {
params.topMargin + (scrollY - scrollView.scrollY)
} else
0
} else
params.topMargin - scrollView.scrollY + scrollY
if ((temp < 0) && !(upDown)) {//Двигаем panel в зависимости от прокрутки
params.topMargin = 0
} else if ((temp > maxScroll) && (upDown)) {
params.topMargin = maxScroll
} else {
params.topMargin = temp
}
panel.layoutParams = params//Присваиваем изменения
scrollY = scrollView.scrollY
}
Выглядит страшно, но если посидеть 5 минут и разобраться как это работает, то всё сразу станет ясно. Исчерпывающие комментарии находятся непосредственно в коде.
Давайте запустим и проверим результат.
Как мы видим, всё работает прекрасно, не учитывая пару багов*. Ползунок, показывающий где мы находимся в ScrollView, пересекается с нашей “панелью инструментов”. И самым простым решением будет отключить его насовсем. Делается это в xml файле таким параметром в ScrollView.
android:scrollbars="none"
2-й проблемой является то, что наша панель периодически сливается с зоной контента. Эту проблему можно решить динамическим добавлением тенью.
private fun alphaElevation() {
val params =
panel.layoutParams as ViewGroup.MarginLayoutParams
panelText.alpha =
(1 - (scrollY.toFloat() * 100.0 / maxScroll.toFloat() / 100.0) / 0.5).toFloat()//Плавное исчезновение/появление panelText
theBigText.alpha = (scrollY.toFloat() * 100.0 / maxScroll.toFloat() / 100.0).toFloat()//Плавное исчезновение/появление большого текста
//Если вдруг захотите, чтобы иконка тоже появлялась плавно, раскомментируете данный участок
// var settingsAlpha =
// ((scrollY.toFloat() * 100.0 / maxScroll.toFloat() / 100.0)).toFloat()
// if (settingsAlpha < 0.7f)
// CompasIcon.alpha = settingsAlpha
// else
// CompasIcon.alpha = 0.7f
//Если panel достигла верхнего края экрана -> добавить тень
if (params.topMargin == 0)
panel.elevation = 10f
else
panel.elevation = 0.1f
}
Теперь наш слушатель ScrollView должен выглядеть так.
scrollView.viewTreeObserver.addOnScrollChangedListener(ViewTreeObserver.OnScrollChangedListener { //Включается когда производится скролл
scrollEngine()
alphaElevation()
})
И снова проверим результат
Магия магнитов
Потрясающе, но есть одно но. “Панель инструментов” имеет 2 стандартных положения:
Когда panel примагничено к верхнему краю экрана.
Изначальное положение.
Так вот, в настоящем OneUI ScrollView смещается, если “панель инструментов” находится между этими положениями. И смещается оно в ту сторону, к которой ближе находится. Звучит возможно не понятно, но на следующей gif анимации всё будет показано.
Дополнительная проблема в том, что нам недостаточно отслеживать, где находится “панель инструментов”, нам необходимо знать, когда пользователь перестал взаимодействовать с экраном. Проще говоря - поднял палец. И для плавности работы и более лучшего внешнего вида, нам придётся применить задержку в какое-то количество секунд, ведь ScrollView может быть ещё в движении.
Начнём решать проблему с самых низов.
Необходимо узнать, когда пользователь прикасался к экрану, а когда нет. В этом нам поможет специальный слушатель.
//Проверка нажатия на экран
@SuppressLint("ClickableViewAccessibility")
fun touchFun() {
scrollView.setOnTouchListener { v, event ->
val action = event.action
when (action) {
MotionEvent.ACTION_DOWN -> {
false
}
MotionEvent.ACTION_UP -> {
threadTimer()
false
}
MotionEvent.ACTION_CANCEL -> {
false
}
MotionEvent.ACTION_OUTSIDE -> {
false
}
else -> false
}
}
}
Проверка на нажатие есть. Теперь необходимо создать функцию, которая будет плавно примагничивать и двигать наш ScrollView.
//Таймер+приведение panel в стандартное положение
private fun threadTimer() {
var lastScrollY = 0
//Мы не имеем права использовать заморозки в главном UI потоке, поэтому вынуждены создать новый
Thread {
//Пока новое положение ScrollView не сравнится со старым
while (scrollView.scaleY.toInt() != lastScrollY) {
lastScrollY = scrollView.scrollY
//Пол секунды
Thread.sleep(500)
//Если текущее положение не равняется предыдущему
if (scrollView.scaleY.toInt() != lastScrollY)
lastScrollY = scrollView.scaleY.toInt()
}
//Если положение ScrollView меньше, чем максимальное положение panel
if (scrollY < maxScroll) {
//Елси ScrollView ближе к максимальному значению panel
if (scrollY >= maxScroll / 3)//Плавный скролл
//Так как мы не можем изменять UI не из главного потока, мы используем post
scrollView.post { scrollView.smoothScrollTo(0, maxScroll) }
//Если ScrollView ближе к верхнему краю экрана
else if (scrollY < maxScroll / 3)
//Так как мы не можем изменять UI не из главного потока, мы используем post
scrollView.post { scrollView.smoothScrollTo(0, 0) }
}
}.start()
}
Итог
Вот теперь это выглядит потрясающе, ну на мой сугубо личный взгляд, и мы смогли повторить ту самую идею, которую несёт в себе оболочка OneUI. Так же ли всё работает у Samsung? Конечно же нет. Но тот способ, который я описал, позволит вам лучше понять всё происходящее здесь. Так как у нас есть свои собственные отличительные черты, я предлагаю назвать наше дизайнерское решение Siesta 1.0. Надеюсь, вам поможет данная статья, т.к. в своё время, её мне очень не хватало и во всём разбираться пришлось с 0. Комментируете, если что-то не понятно, ну и конечно делитесь своим мнением, как вам моё детище и One UI.
Данное приложение вы можете найти на GitHub. Бонусом идёт пример приложения использующее Siesta 1.0, оно спрятано в подсказке.
Комментарии (5)
anegin
27.08.2021 13:21+4Поздравляю, вы почти изобрели CollapsingToolbarLayout, который делает то же самое и даже лучше, но без единой строчки кода, только в xml. Даже с MotionLayout можно сделать такое же поведение с минимумом кода. По всей статье прослеживается очень много "плохих практик":
даже если делать такое поведение самостоятельно, то в данном случае лучше анимировать translationY контента, а не его topMargin, т.к. это приводит к re-layout/re-measure
запуск неконтролируемого потока для анимации при каждом touch up, хотя есть куча нативных инструментов для анимации
использование LinearLayout (вместо RecyclerView) для списка из элементов
необоснованный выбор min sdk аж 26 - в коде нет ничего, что требовало бы минимум эту версию
очень много мелких замечаний по коду и верстке, которые надоест тут описывать
Paul85
02.09.2021 15:20+1неплохо выглядит. А что если внутрь надо засетить список, а не просто карточки? Мне кажется можно тогда вместо scrollview просто использовать recyclerview.
lonely_programmer Автор
02.09.2021 15:23Да, использование RecyclerView будет более оптимизированным способом, однако, он гораздо сложнее в использовании. Необходимо создать отдельный класс с адаптером и т.д. + мы не знаем позицию скролла в RecyclerView (на столько же точную, как в ScrollView). Поэтому я решил, для первой версии Siesta использовать ScrollView - дабы кода было меньше, а понять было легче.
AlexSkvortsov
Пробежал статью глазами и так и не понял, в чем суть.
У стандартного для андроида MaterialDesign описаны компоненты, которые тут ваяются, есть хорошо известные нативные способы реализации в верстке, функционал присутствует в каждом первом приложении. Что за OneUI, каким он тут вообще боком?!
Третья анимация вот тут примерно то же самое.
https://material.io/components/app-bars-top#behavior
По той же ссылке можно найти способ реализации и описание всех возможностей