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
Информация взята из Android Studio
Информация взята из Android Studio
https://www.appbrain.com/stats/top-android-sdk-versions
https://www.appbrain.com/stats/top-android-sdk-versions

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 стандартных положения:

  1. Когда panel примагничено к верхнему краю экрана.

  2. Изначальное положение.

Так вот, в настоящем 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)


  1. AlexSkvortsov
    27.08.2021 12:42
    +2

    Пробежал статью глазами и так и не понял, в чем суть.

    У стандартного для андроида MaterialDesign описаны компоненты, которые тут ваяются, есть хорошо известные нативные способы реализации в верстке, функционал присутствует в каждом первом приложении. Что за OneUI, каким он тут вообще боком?!

    Третья анимация вот тут примерно то же самое.
    https://material.io/components/app-bars-top#behavior
    По той же ссылке можно найти способ реализации и описание всех возможностей


  1. anegin
    27.08.2021 13:21
    +4

    Поздравляю, вы почти изобрели CollapsingToolbarLayout, который делает то же самое и даже лучше, но без единой строчки кода, только в xml. Даже с MotionLayout можно сделать такое же поведение с минимумом кода. По всей статье прослеживается очень много "плохих практик":

    • даже если делать такое поведение самостоятельно, то в данном случае лучше анимировать translationY контента, а не его topMargin, т.к. это приводит к re-layout/re-measure

    • запуск неконтролируемого потока для анимации при каждом touch up, хотя есть куча нативных инструментов для анимации

    • использование LinearLayout (вместо RecyclerView) для списка из элементов

    • необоснованный выбор min sdk аж 26 - в коде нет ничего, что требовало бы минимум эту версию

    • очень много мелких замечаний по коду и верстке, которые надоест тут описывать


  1. alexinzaz
    27.08.2021 15:01

    Мне кажется или Вы только что изобрели Coordinator Layout?


  1. Paul85
    02.09.2021 15:20
    +1

    неплохо выглядит. А что если внутрь надо засетить список, а не просто карточки? Мне кажется можно тогда вместо scrollview просто использовать recyclerview.


    1. lonely_programmer Автор
      02.09.2021 15:23

      Да, использование RecyclerView будет более оптимизированным способом, однако, он гораздо сложнее в использовании. Необходимо создать отдельный класс с адаптером и т.д. + мы не знаем позицию скролла в RecyclerView (на столько же точную, как в ScrollView). Поэтому я решил, для первой версии Siesta использовать ScrollView - дабы кода было меньше, а понять было легче.