Merge changes I75a386d7,Ica8c2b14 into rvc-dev

* changes:
  Fixes an issue where the media players would animate on screen off
  Replacing MotionLayout with DynamicLayout
This commit is contained in:
Selim Cinek
2020-05-27 01:43:45 +00:00
committed by Android (Google) Code Review
33 changed files with 1984 additions and 1171 deletions

View File

@@ -16,7 +16,7 @@
-->
<!-- Layout for media controls inside QSPanel carousel -->
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
<com.android.systemui.util.animation.TransitionLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/qs_media_controls"
android:layout_width="match_parent"
@@ -24,18 +24,7 @@
android:clipChildren="false"
android:clipToPadding="false"
android:gravity="center_horizontal|fill_vertical"
app:layoutDescription="@xml/media_scene">
<View
android:id="@+id/media_background"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/qs_media_background"
app:layout_constraintEnd_toEndOf="@id/view_width"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
/>
android:background="@drawable/qs_media_background">
<FrameLayout
android:id="@+id/notification_media_progress_time"
@@ -185,15 +174,5 @@
<!-- Buttons to remove this view when no longer needed -->
<include
layout="@layout/qs_media_panel_options"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="@id/view_width"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/view_width"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_begin="300dp" />
</androidx.constraintlayout.motion.widget.MotionLayout>
android:visibility="gone" />
</com.android.systemui.util.animation.TransitionLayout>

View File

@@ -90,6 +90,8 @@
<item type="id" name="view_width_animator_end_tag"/>
<item type="id" name="view_width_current_value"/>
<item type="id" name="requires_remeasuring"/>
<!-- Whether the icon is from a notification for which targetSdk < L -->
<item type="id" name="icon_is_pre_L"/>

View File

@@ -0,0 +1,184 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License
-->
<ConstraintSet
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<Constraint
android:id="@+id/icon"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="18dp"
android:layout_marginTop="22dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>
<Constraint
android:id="@+id/app_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:layout_marginStart="10dp"
android:layout_marginTop="20dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintEnd_toStartOf="@id/media_seamless"
app:layout_constraintHorizontal_bias="0"
/>
<Constraint
android:id="@+id/media_seamless"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_min="60dp"
android:layout_marginTop="@dimen/qs_media_panel_outer_padding"
android:layout_marginEnd="@dimen/qs_media_panel_outer_padding"
/>
<Constraint
android:id="@+id/album_art"
android:layout_width="@dimen/qs_media_album_size"
android:layout_height="@dimen/qs_media_album_size"
android:layout_marginTop="16dp"
android:layout_marginStart="@dimen/qs_media_panel_outer_padding"
android:layout_marginBottom="24dp"
app:layout_constraintTop_toBottomOf="@id/icon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
/>
<!-- Song name -->
<Constraint
android:id="@+id/header_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="17dp"
android:layout_marginStart="16dp"
app:layout_constraintTop_toBottomOf="@id/app_name"
app:layout_constraintBottom_toTopOf="@id/header_artist"
app:layout_constraintStart_toEndOf="@id/album_art"
app:layout_constraintEnd_toStartOf="@id/action0"
app:layout_constraintHorizontal_bias="0"/>
<!-- Artist name -->
<Constraint
android:id="@+id/header_artist"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:layout_marginBottom="24dp"
app:layout_constraintTop_toBottomOf="@id/header_title"
app:layout_constraintStart_toStartOf="@id/header_title"
app:layout_constraintEnd_toStartOf="@id/action0"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHorizontal_bias="0"/>
<!-- Seek Bar -->
<Constraint
android:id="@+id/media_progress_bar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:alpha="0.0"
app:layout_constraintTop_toBottomOf="@id/album_art"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:visibility="gone"
/>
<Constraint
android:id="@+id/notification_media_progress_time"
android:alpha="0.0"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="35dp"
android:layout_marginEnd="@dimen/qs_media_panel_outer_padding"
android:layout_marginStart="@dimen/qs_media_panel_outer_padding"
app:layout_constraintTop_toBottomOf="@id/album_art"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:visibility="gone"
/>
<Constraint
android:id="@+id/action0"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginTop="16dp"
android:visibility="gone"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintTop_toBottomOf="@id/app_name"
app:layout_constraintLeft_toRightOf="@id/header_title"
app:layout_constraintRight_toLeftOf="@id/action1"
>
</Constraint>
<Constraint
android:id="@+id/action1"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginTop="18dp"
app:layout_constraintTop_toBottomOf="@id/app_name"
app:layout_constraintLeft_toRightOf="@id/action0"
app:layout_constraintRight_toLeftOf="@id/action2"
>
</Constraint>
<Constraint
android:id="@+id/action2"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginTop="18dp"
app:layout_constraintTop_toBottomOf="@id/app_name"
app:layout_constraintLeft_toRightOf="@id/action1"
app:layout_constraintRight_toLeftOf="@id/action3"
>
</Constraint>
<Constraint
android:id="@+id/action3"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginTop="18dp"
app:layout_constraintTop_toBottomOf="@id/app_name"
app:layout_constraintLeft_toRightOf="@id/action2"
app:layout_constraintRight_toLeftOf="@id/action4"
>
</Constraint>
<Constraint
android:id="@+id/action4"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:visibility="gone"
android:layout_marginTop="18dp"
app:layout_constraintTop_toBottomOf="@id/app_name"
app:layout_constraintLeft_toRightOf="@id/action3"
app:layout_constraintRight_toRightOf="parent"
>
</Constraint>
</ConstraintSet>

View File

@@ -0,0 +1,178 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License
-->
<ConstraintSet
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<Constraint
android:id="@+id/icon"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="18dp"
android:layout_marginTop="22dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>
<Constraint
android:id="@+id/app_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:layout_marginStart="10dp"
android:layout_marginTop="20dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintEnd_toStartOf="@id/media_seamless"
app:layout_constraintHorizontal_bias="0"
/>
<Constraint
android:id="@+id/media_seamless"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_min="60dp"
android:layout_marginTop="@dimen/qs_media_panel_outer_padding"
android:layout_marginEnd="@dimen/qs_media_panel_outer_padding"
/>
<Constraint
android:id="@+id/album_art"
android:layout_width="@dimen/qs_media_album_size"
android:layout_height="@dimen/qs_media_album_size"
android:layout_marginTop="14dp"
android:layout_marginStart="@dimen/qs_media_panel_outer_padding"
app:layout_constraintTop_toBottomOf="@+id/app_name"
app:layout_constraintStart_toStartOf="parent"
/>
<!-- Song name -->
<Constraint
android:id="@+id/header_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/qs_media_panel_outer_padding"
android:layout_marginTop="17dp"
android:layout_marginStart="16dp"
app:layout_constraintTop_toBottomOf="@+id/app_name"
app:layout_constraintStart_toEndOf="@id/album_art"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"/>
<!-- Artist name -->
<Constraint
android:id="@+id/header_artist"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/qs_media_panel_outer_padding"
android:layout_marginTop="3dp"
app:layout_constraintTop_toBottomOf="@id/header_title"
app:layout_constraintStart_toStartOf="@id/header_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"/>
<!-- Seek Bar -->
<Constraint
android:id="@+id/media_progress_bar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
app:layout_constraintTop_toBottomOf="@id/header_artist"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
/>
<Constraint
android:id="@+id/notification_media_progress_time"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="38dp"
android:layout_marginEnd="@dimen/qs_media_panel_outer_padding"
android:layout_marginStart="@dimen/qs_media_panel_outer_padding"
app:layout_constraintTop_toBottomOf="@id/header_artist"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
/>
<Constraint
android:id="@+id/action0"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="5dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="@dimen/qs_media_panel_outer_padding"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@id/action1"
app:layout_constraintTop_toBottomOf="@id/notification_media_progress_time"
app:layout_constraintBottom_toBottomOf="parent">
</Constraint>
<Constraint
android:id="@+id/action1"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="@dimen/qs_media_panel_outer_padding"
app:layout_constraintLeft_toRightOf="@id/action0"
app:layout_constraintRight_toLeftOf="@id/action2"
app:layout_constraintTop_toTopOf="@id/action0"
app:layout_constraintBottom_toBottomOf="parent">
</Constraint>
<Constraint
android:id="@+id/action2"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="@dimen/qs_media_panel_outer_padding"
app:layout_constraintLeft_toRightOf="@id/action1"
app:layout_constraintRight_toLeftOf="@id/action3"
app:layout_constraintTop_toTopOf="@id/action0"
app:layout_constraintBottom_toBottomOf="parent">
</Constraint>
<Constraint
android:id="@+id/action3"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
app:layout_constraintLeft_toRightOf="@id/action2"
app:layout_constraintRight_toLeftOf="@id/action4"
app:layout_constraintTop_toTopOf="@id/action0"
android:layout_marginBottom="@dimen/qs_media_panel_outer_padding"
app:layout_constraintBottom_toBottomOf="parent">
</Constraint>
<Constraint
android:id="@+id/action4"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="@dimen/qs_media_panel_outer_padding"
app:layout_constraintLeft_toRightOf="@id/action3"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="@id/action0"
app:layout_constraintBottom_toBottomOf="parent">
</Constraint>
</ConstraintSet>

View File

@@ -1,447 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2020 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License
-->
<MotionScene
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<Transition
app:constraintSetStart="@id/collapsed"
app:constraintSetEnd="@id/expanded"
app:duration="1000" >
<KeyFrameSet >
<KeyPosition
app:motionTarget="@+id/action0"
app:keyPositionType="pathRelative"
app:framePosition="70"
app:sizePercent="0.9" />
<KeyPosition
app:motionTarget="@+id/action1"
app:keyPositionType="pathRelative"
app:framePosition="70"
app:sizePercent="0.9" />
<KeyPosition
app:motionTarget="@+id/action2"
app:keyPositionType="pathRelative"
app:framePosition="70"
app:sizePercent="0.9" />
<KeyPosition
app:motionTarget="@+id/action3"
app:keyPositionType="pathRelative"
app:framePosition="70"
app:sizePercent="0.9" />
<KeyPosition
app:motionTarget="@+id/action4"
app:keyPositionType="pathRelative"
app:framePosition="70"
app:sizePercent="0.9" />
<KeyPosition
app:motionTarget="@+id/media_progress_bar"
app:keyPositionType="pathRelative"
app:framePosition="70"
app:sizePercent="0.9" />
<KeyAttribute
app:motionTarget="@id/media_progress_bar"
app:framePosition="0"
android:alpha="0.0" />
<KeyAttribute
app:motionTarget="@+id/media_progress_bar"
app:framePosition="70"
android:alpha="0.0"/>
<KeyPosition
app:motionTarget="@+id/notification_media_progress_time"
app:keyPositionType="pathRelative"
app:framePosition="70"
app:sizePercent="0.9" />
<KeyAttribute
app:motionTarget="@id/notification_media_progress_time"
app:framePosition="0"
android:alpha="0.0" />
<KeyAttribute
app:motionTarget="@+id/notification_media_progress_time"
app:framePosition="70"
android:alpha="0.0"/>
<KeyAttribute
app:motionTarget="@id/action0"
app:framePosition="0"
android:alpha="0.0" />
<KeyAttribute
app:motionTarget="@+id/action0"
app:framePosition="70"
android:alpha="0.0"/>
<KeyAttribute
app:motionTarget="@id/action1"
app:framePosition="0"
android:alpha="0.0" />
<KeyAttribute
app:motionTarget="@+id/action1"
app:framePosition="70"
android:alpha="0.0"/>
<KeyAttribute
app:motionTarget="@id/action2"
app:framePosition="0"
android:alpha="0.0" />
<KeyAttribute
app:motionTarget="@+id/action2"
app:framePosition="70"
android:alpha="0.0"/>
<KeyAttribute
app:motionTarget="@id/action3"
app:framePosition="0"
android:alpha="0.0" />
<KeyAttribute
app:motionTarget="@+id/action3"
app:framePosition="70"
android:alpha="0.0"/>
<KeyAttribute
app:motionTarget="@id/action4"
app:framePosition="0"
android:alpha="0.0" />
<KeyAttribute
app:motionTarget="@+id/action4"
app:framePosition="70"
android:alpha="0.0"/>
</KeyFrameSet>
</Transition>
<ConstraintSet android:id="@+id/expanded">
<Constraint
android:id="@+id/icon"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="18dp"
android:layout_marginTop="22dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>
<Constraint
android:id="@+id/app_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:layout_marginStart="10dp"
android:layout_marginTop="20dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintEnd_toStartOf="@id/media_seamless"
app:layout_constraintHorizontal_bias="0"
/>
<Constraint
android:id="@+id/media_seamless"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="@id/view_width"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_min="60dp"
android:layout_marginTop="@dimen/qs_media_panel_outer_padding"
android:layout_marginEnd="@dimen/qs_media_panel_outer_padding"
/>
<Constraint
android:id="@+id/album_art"
android:layout_width="@dimen/qs_media_album_size"
android:layout_height="@dimen/qs_media_album_size"
android:layout_marginTop="14dp"
android:layout_marginStart="@dimen/qs_media_panel_outer_padding"
app:layout_constraintTop_toBottomOf="@+id/app_name"
app:layout_constraintStart_toStartOf="parent"
/>
<!-- Song name -->
<Constraint
android:id="@+id/header_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/qs_media_panel_outer_padding"
android:layout_marginTop="17dp"
android:layout_marginStart="16dp"
app:layout_constraintTop_toBottomOf="@+id/app_name"
app:layout_constraintStart_toEndOf="@id/album_art"
app:layout_constraintEnd_toEndOf="@id/view_width"
app:layout_constraintHorizontal_bias="0"/>
<!-- Artist name -->
<Constraint
android:id="@+id/header_artist"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/qs_media_panel_outer_padding"
android:layout_marginTop="3dp"
app:layout_constraintTop_toBottomOf="@id/header_title"
app:layout_constraintStart_toStartOf="@id/header_title"
app:layout_constraintEnd_toEndOf="@id/view_width"
app:layout_constraintHorizontal_bias="0"/>
<!-- Seek Bar -->
<Constraint
android:id="@+id/media_progress_bar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
app:layout_constraintTop_toBottomOf="@id/header_artist"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="@id/view_width"
/>
<Constraint
android:id="@+id/notification_media_progress_time"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="38dp"
android:layout_marginEnd="@dimen/qs_media_panel_outer_padding"
android:layout_marginStart="@dimen/qs_media_panel_outer_padding"
app:layout_constraintTop_toBottomOf="@id/header_artist"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="@id/view_width"
/>
<Constraint
android:id="@+id/action0"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="5dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="@dimen/qs_media_panel_outer_padding"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@id/action1"
app:layout_constraintTop_toBottomOf="@id/notification_media_progress_time"
app:layout_constraintBottom_toBottomOf="parent">
</Constraint>
<Constraint
android:id="@+id/action1"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="@dimen/qs_media_panel_outer_padding"
app:layout_constraintLeft_toRightOf="@id/action0"
app:layout_constraintRight_toLeftOf="@id/action2"
app:layout_constraintTop_toTopOf="@id/action0"
app:layout_constraintBottom_toBottomOf="parent">
</Constraint>
<Constraint
android:id="@+id/action2"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="@dimen/qs_media_panel_outer_padding"
app:layout_constraintLeft_toRightOf="@id/action1"
app:layout_constraintRight_toLeftOf="@id/action3"
app:layout_constraintTop_toTopOf="@id/action0"
app:layout_constraintBottom_toBottomOf="parent">
</Constraint>
<Constraint
android:id="@+id/action3"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
app:layout_constraintLeft_toRightOf="@id/action2"
app:layout_constraintRight_toLeftOf="@id/action4"
app:layout_constraintTop_toTopOf="@id/action0"
android:layout_marginBottom="@dimen/qs_media_panel_outer_padding"
app:layout_constraintBottom_toBottomOf="parent">
</Constraint>
<Constraint
android:id="@+id/action4"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="@dimen/qs_media_panel_outer_padding"
app:layout_constraintLeft_toRightOf="@id/action3"
app:layout_constraintRight_toRightOf="@id/view_width"
app:layout_constraintTop_toTopOf="@id/action0"
app:layout_constraintBottom_toBottomOf="parent">
</Constraint>
</ConstraintSet>
<ConstraintSet android:id="@+id/collapsed">
<Constraint
android:id="@+id/icon"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="18dp"
android:layout_marginTop="22dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>
<Constraint
android:id="@+id/app_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:layout_marginStart="10dp"
android:layout_marginTop="20dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintEnd_toStartOf="@id/media_seamless"
app:layout_constraintHorizontal_bias="0"
/>
<Constraint
android:id="@+id/media_seamless"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="@id/view_width"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_min="60dp"
android:layout_marginTop="@dimen/qs_media_panel_outer_padding"
android:layout_marginEnd="@dimen/qs_media_panel_outer_padding"
/>
<Constraint
android:id="@+id/album_art"
android:layout_width="@dimen/qs_media_album_size"
android:layout_height="@dimen/qs_media_album_size"
android:layout_marginTop="16dp"
android:layout_marginStart="@dimen/qs_media_panel_outer_padding"
android:layout_marginBottom="24dp"
app:layout_constraintTop_toBottomOf="@id/icon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
/>
<!-- Song name -->
<Constraint
android:id="@+id/header_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="17dp"
android:layout_marginStart="16dp"
app:layout_constraintTop_toBottomOf="@id/app_name"
app:layout_constraintBottom_toTopOf="@id/header_artist"
app:layout_constraintStart_toEndOf="@id/album_art"
app:layout_constraintEnd_toStartOf="@id/action0"
app:layout_constraintHorizontal_bias="0"/>
<!-- Artist name -->
<Constraint
android:id="@+id/header_artist"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:layout_marginBottom="24dp"
app:layout_constraintTop_toBottomOf="@id/header_title"
app:layout_constraintStart_toStartOf="@id/header_title"
app:layout_constraintEnd_toStartOf="@id/action0"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHorizontal_bias="0"/>
<!-- Seek Bar -->
<Constraint
android:id="@+id/media_progress_bar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:alpha="0.0"
app:layout_constraintTop_toBottomOf="@id/album_art"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="@id/view_width"
android:visibility="gone"
/>
<Constraint
android:id="@+id/notification_media_progress_time"
android:alpha="0.0"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="35dp"
android:layout_marginEnd="@dimen/qs_media_panel_outer_padding"
android:layout_marginStart="@dimen/qs_media_panel_outer_padding"
app:layout_constraintTop_toBottomOf="@id/album_art"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="@id/view_width"
android:visibility="gone"
/>
<Constraint
android:id="@+id/action0"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginTop="16dp"
android:visibility="gone"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintTop_toBottomOf="@id/app_name"
app:layout_constraintLeft_toRightOf="@id/header_title"
app:layout_constraintRight_toLeftOf="@id/action1"
>
</Constraint>
<Constraint
android:id="@+id/action1"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginTop="18dp"
app:layout_constraintTop_toBottomOf="@id/app_name"
app:layout_constraintLeft_toRightOf="@id/action0"
app:layout_constraintRight_toLeftOf="@id/action2"
>
</Constraint>
<Constraint
android:id="@+id/action2"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginTop="18dp"
app:layout_constraintTop_toBottomOf="@id/app_name"
app:layout_constraintLeft_toRightOf="@id/action1"
app:layout_constraintRight_toLeftOf="@id/action3"
>
</Constraint>
<Constraint
android:id="@+id/action3"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginTop="18dp"
app:layout_constraintTop_toBottomOf="@id/app_name"
app:layout_constraintLeft_toRightOf="@id/action2"
app:layout_constraintRight_toLeftOf="@id/action4"
>
</Constraint>
<Constraint
android:id="@+id/action4"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:visibility="gone"
android:layout_marginTop="18dp"
app:layout_constraintTop_toBottomOf="@id/app_name"
app:layout_constraintLeft_toRightOf="@id/action3"
app:layout_constraintRight_toRightOf="@id/view_width"
>
</Constraint>
</ConstraintSet>
</MotionScene>

View File

@@ -1,52 +0,0 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package com.android.systemui.media
import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
private val EMPTY_RECT = Rect(0,0,0,0)
private val LAYOUT_CHANGE_LISTENER = object : View.OnLayoutChangeListener {
override fun onLayoutChange(v: View?, left: Int, top: Int, right: Int, bottom: Int,
oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
v?.let {
if (v.visibility == View.GONE) {
v.clipBounds = EMPTY_RECT
} else {
v.clipBounds = null
}
}
}
}
/**
* A helper class that clips all GONE children. Useful for transitions in motionlayout which
* don't clip its children.
*/
class GoneChildrenHideHelper private constructor() {
companion object {
@JvmStatic
fun clipGoneChildrenOnLayout(layout: ViewGroup) {
val childCount = layout.childCount
for (i in 0 until childCount) {
val child = layout.getChildAt(i)
child.addOnLayoutChangeListener(LAYOUT_CHANGE_LISTENER)
}
}
}
}

View File

@@ -45,6 +45,7 @@ class KeyguardMediaController @Inject constructor(
}
})
}
private var view: MediaHeaderView? = null
/**

View File

@@ -1,115 +0,0 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package com.android.systemui.media
import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import com.android.systemui.statusbar.notification.AnimatableProperty
import com.android.systemui.statusbar.notification.PropertyAnimator
import com.android.systemui.statusbar.notification.stack.AnimationProperties
/**
* A utility class that helps with animations of bound changes designed for motionlayout which
* doesn't work together with regular changeBounds.
*/
class LayoutAnimationHelper {
private val layout: ViewGroup
private var sizeAnimationPending = false
private val desiredBounds = mutableMapOf<View, Rect>()
private val animationProperties = AnimationProperties()
private val layoutListener = object : View.OnLayoutChangeListener {
override fun onLayoutChange(v: View?, left: Int, top: Int, right: Int, bottom: Int,
oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
v?.let {
if (v.alpha == 0.0f || v.visibility == View.GONE || oldLeft - oldRight == 0 ||
oldTop - oldBottom == 0) {
return
}
if (oldLeft != left || oldTop != top || oldBottom != bottom || oldRight != right) {
val rect = desiredBounds.getOrPut(v, { Rect() })
rect.set(left, top, right, bottom)
onDesiredLocationChanged(v, rect)
}
}
}
}
constructor(layout: ViewGroup) {
this.layout = layout
val childCount = this.layout.childCount
for (i in 0 until childCount) {
val child = this.layout.getChildAt(i)
child.addOnLayoutChangeListener(layoutListener)
}
}
private fun onDesiredLocationChanged(v: View, rect: Rect) {
if (!sizeAnimationPending) {
applyBounds(v, rect, animate = false)
}
// We need to reapply the current bounds in every frame since the layout may override
// the layout bounds making this view jump and not all calls to apply bounds actually
// reapply them, for example if there's already an animator to the same target
reapplyProperty(v, AnimatableProperty.ABSOLUTE_X);
reapplyProperty(v, AnimatableProperty.ABSOLUTE_Y);
reapplyProperty(v, AnimatableProperty.WIDTH);
reapplyProperty(v, AnimatableProperty.HEIGHT);
}
private fun reapplyProperty(v: View, property: AnimatableProperty) {
property.property.set(v, property.property.get(v))
}
private fun applyBounds(v: View, newBounds: Rect, animate: Boolean) {
PropertyAnimator.setProperty(v, AnimatableProperty.ABSOLUTE_X, newBounds.left.toFloat(),
animationProperties, animate)
PropertyAnimator.setProperty(v, AnimatableProperty.ABSOLUTE_Y, newBounds.top.toFloat(),
animationProperties, animate)
PropertyAnimator.setProperty(v, AnimatableProperty.WIDTH, newBounds.width().toFloat(),
animationProperties, animate)
PropertyAnimator.setProperty(v, AnimatableProperty.HEIGHT, newBounds.height().toFloat(),
animationProperties, animate)
}
private fun startBoundAnimation(v: View) {
val target = desiredBounds[v] ?: return
applyBounds(v, target, animate = true)
}
fun animatePendingSizeChange(duration: Long, delay: Long) {
animationProperties.duration = duration
animationProperties.delay = delay
if (!sizeAnimationPending) {
sizeAnimationPending = true
layout.viewTreeObserver.addOnPreDrawListener (
object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
layout.viewTreeObserver.removeOnPreDrawListener(this)
sizeAnimationPending = false
val childCount = layout.childCount
for (i in 0 until childCount) {
val child = layout.getChildAt(i)
startBoundAnimation(child)
}
return true
}
})
}
}
}

View File

@@ -43,12 +43,9 @@ import android.widget.ImageView;
import android.widget.SeekBar;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.constraintlayout.motion.widget.Key;
import androidx.constraintlayout.motion.widget.KeyAttributes;
import androidx.constraintlayout.motion.widget.KeyFrames;
import androidx.constraintlayout.motion.widget.MotionLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.core.graphics.drawable.RoundedBitmapDrawable;
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
@@ -59,11 +56,11 @@ import com.android.settingslib.widget.AdaptiveIcon;
import com.android.systemui.R;
import com.android.systemui.plugins.ActivityStarter;
import com.android.systemui.qs.QSMediaBrowser;
import com.android.systemui.util.animation.TransitionLayout;
import com.android.systemui.util.concurrency.DelayableExecutor;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executor;
@@ -87,16 +84,15 @@ public class MediaControlPanel {
private final Executor mForegroundExecutor;
protected final Executor mBackgroundExecutor;
private final ActivityStarter mActivityStarter;
private LayoutAnimationHelper mLayoutAnimationHelper;
private Context mContext;
private PlayerViewHolder mViewHolder;
private MediaViewController mMediaViewController;
private MediaSession.Token mToken;
private MediaController mController;
private int mBackgroundColor;
protected ComponentName mServiceComponent;
private boolean mIsRegistered = false;
private List<KeyFrames> mKeyFrames;
private String mKey;
private int mAlbumArtSize;
private int mAlbumArtRadius;
@@ -133,12 +129,14 @@ public class MediaControlPanel {
* @param activityStarter activity starter
*/
public MediaControlPanel(Context context, Executor foregroundExecutor,
DelayableExecutor backgroundExecutor, ActivityStarter activityStarter) {
DelayableExecutor backgroundExecutor, ActivityStarter activityStarter,
MediaHostStatesManager mediaHostStatesManager) {
mContext = context;
mForegroundExecutor = foregroundExecutor;
mBackgroundExecutor = backgroundExecutor;
mActivityStarter = activityStarter;
mSeekBarViewModel = new SeekBarViewModel(backgroundExecutor);
mMediaViewController = new MediaViewController(context, mediaHostStatesManager);
loadDimens();
}
@@ -147,6 +145,7 @@ public class MediaControlPanel {
mSeekBarViewModel.getProgress().removeObserver(mSeekBarObserver);
}
mSeekBarViewModel.onDestroy();
mMediaViewController.onDestroy();
}
private void loadDimens() {
@@ -164,6 +163,15 @@ public class MediaControlPanel {
return mViewHolder;
}
/**
* Get the view controller used to display media controls
* @return the media view controller
*/
@NonNull
public MediaViewController getMediaViewController() {
return mMediaViewController;
}
/**
* Sets the listening state of the player.
*
@@ -187,15 +195,13 @@ public class MediaControlPanel {
/** Attaches the player to the view holder. */
public void attach(PlayerViewHolder vh) {
mViewHolder = vh;
MotionLayout motionView = vh.getPlayer();
mLayoutAnimationHelper = new LayoutAnimationHelper(motionView);
GoneChildrenHideHelper.clipGoneChildrenOnLayout(motionView);
mKeyFrames = motionView.getDefinedTransitions().get(0).getKeyFrameList();
TransitionLayout player = vh.getPlayer();
mSeekBarObserver = new SeekBarObserver(vh);
mSeekBarViewModel.getProgress().observeForever(mSeekBarObserver);
SeekBar bar = vh.getSeekBar();
bar.setOnSeekBarChangeListener(mSeekBarViewModel.getSeekBarListener());
bar.setOnTouchListener(mSeekBarViewModel.getSeekBarTouchListener());
mMediaViewController.attach(player);
}
/**
@@ -220,8 +226,8 @@ public class MediaControlPanel {
mController = new MediaController(mContext, mToken);
ConstraintSet expandedSet = mViewHolder.getPlayer().getConstraintSet(R.id.expanded);
ConstraintSet collapsedSet = mViewHolder.getPlayer().getConstraintSet(R.id.collapsed);
ConstraintSet expandedSet = mMediaViewController.getExpandedLayout();
ConstraintSet collapsedSet = mMediaViewController.getCollapsedLayout();
// Try to find a browser service component for this app
// TODO also check for a media button receiver intended for restarting (b/154127084)
@@ -247,7 +253,7 @@ public class MediaControlPanel {
mController.registerCallback(mSessionCallback);
mViewHolder.getBackground().setBackgroundTintList(
mViewHolder.getPlayer().setBackgroundTintList(
ColorStateList.valueOf(mBackgroundColor));
// Click action
@@ -356,7 +362,6 @@ public class MediaControlPanel {
}
});
boolean visibleInCompat = actionsWhenCollapsed.contains(i);
updateKeyFrameVisibility(actionId, visibleInCompat);
setVisibleAndAlpha(collapsedSet, actionId, visibleInCompat);
setVisibleAndAlpha(expandedSet, actionId, true /*visible */);
}
@@ -374,9 +379,9 @@ public class MediaControlPanel {
// Set up long press menu
// TODO: b/156036025 bring back media guts
// Update both constraint sets to regenerate the animation.
mViewHolder.getPlayer().updateState(R.id.collapsed, collapsedSet);
mViewHolder.getPlayer().updateState(R.id.expanded, expandedSet);
// TODO: We don't need to refresh this state constantly, only if the state actually changed
// to something which might impact the measurement
mMediaViewController.refreshState();
}
@UiThread
@@ -411,30 +416,6 @@ public class MediaControlPanel {
return artwork;
}
/**
* Updates the keyframe visibility such that only views that are not visible actually go
* through a transition and fade in.
*
* @param actionId the id to change
* @param visible is the view visible
*/
private void updateKeyFrameVisibility(int actionId, boolean visible) {
if (mKeyFrames == null) {
return;
}
for (int i = 0; i < mKeyFrames.size(); i++) {
KeyFrames keyframe = mKeyFrames.get(i);
ArrayList<Key> viewKeyFrames = keyframe.getKeyFramesForView(actionId);
for (int j = 0; j < viewKeyFrames.size(); j++) {
Key key = viewKeyFrames.get(j);
if (key instanceof KeyAttributes) {
KeyAttributes attributes = (KeyAttributes) key;
attributes.setValue("alpha", visible ? 1.0f : 0.0f);
}
}
}
}
/**
* Return the token for the current media session
* @return the token
@@ -528,8 +509,8 @@ public class MediaControlPanel {
}
// Hide all the old buttons
ConstraintSet expandedSet = mViewHolder.getPlayer().getConstraintSet(R.id.expanded);
ConstraintSet collapsedSet = mViewHolder.getPlayer().getConstraintSet(R.id.collapsed);
ConstraintSet expandedSet = mMediaViewController.getExpandedLayout();
ConstraintSet collapsedSet = mMediaViewController.getCollapsedLayout();
for (int i = 1; i < ACTION_IDS.length; i++) {
setVisibleAndAlpha(expandedSet, ACTION_IDS[i], false /*visible */);
setVisibleAndAlpha(collapsedSet, ACTION_IDS[i], false /*visible */);
@@ -571,6 +552,7 @@ public class MediaControlPanel {
options.setVisibility(View.VISIBLE);
return true; // consumed click
});
mMediaViewController.refreshState();
}
private void setVisibleAndAlpha(ConstraintSet set, int actionId, boolean visible) {
@@ -649,33 +631,4 @@ public class MediaControlPanel {
* Called when a player can't be resumed to give it an opportunity to hide or remove itself
*/
protected void removePlayer() { }
public void measure(@Nullable MediaMeasurementInput input) {
if (mViewHolder == null) {
return;
}
if (input != null) {
int width = input.getWidth();
setPlayerWidth(width);
mViewHolder.getPlayer().measure(input.getWidthMeasureSpec(),
input.getHeightMeasureSpec());
}
}
public void setPlayerWidth(int width) {
if (mViewHolder == null) {
return;
}
MotionLayout view = mViewHolder.getPlayer();
ConstraintSet expandedSet = view.getConstraintSet(R.id.expanded);
ConstraintSet collapsedSet = view.getConstraintSet(R.id.collapsed);
collapsedSet.setGuidelineBegin(R.id.view_width, width);
expandedSet.setGuidelineBegin(R.id.view_width, width);
view.updateState(R.id.collapsed, collapsedSet);
view.updateState(R.id.expanded, expandedSet);
}
public void animatePendingSizeChange(long duration, long startDelay) {
mLayoutAnimationHelper.animatePendingSizeChange(duration, startDelay);
}
}

View File

@@ -21,10 +21,13 @@ import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.annotation.IntDef
import android.content.Context
import android.graphics.Rect
import android.util.MathUtils
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroupOverlay
import com.android.systemui.Interpolators
import com.android.systemui.keyguard.WakefulnessLifecycle
import com.android.systemui.plugins.statusbar.StatusBarStateController
import com.android.systemui.statusbar.NotificationLockscreenUserManager
import com.android.systemui.statusbar.StatusBarState
@@ -47,8 +50,8 @@ class MediaHierarchyManager @Inject constructor(
private val keyguardStateController: KeyguardStateController,
private val bypassController: KeyguardBypassController,
private val mediaViewManager: MediaViewManager,
private val mediaMeasurementProvider: MediaMeasurementManager,
private val notifLockscreenUserManager: NotificationLockscreenUserManager
private val notifLockscreenUserManager: NotificationLockscreenUserManager,
wakefulnessLifecycle: WakefulnessLifecycle
) {
/**
* The root overlay of the hierarchy. This is where the media notification is attached to
@@ -56,23 +59,31 @@ class MediaHierarchyManager @Inject constructor(
* view is always in its final state when it is attached to a view host.
*/
private var rootOverlay: ViewGroupOverlay? = null
private lateinit var currentState: MediaState
private val mediaCarousel
private var rootView: View? = null
private var currentBounds = Rect()
private var animationStartBounds: Rect = Rect()
private var targetBounds: Rect = Rect()
private val mediaFrame
get() = mediaViewManager.mediaFrame
private var animationStartState: MediaState? = null
private var statusbarState: Int = statusBarStateController.state
private var animator = ValueAnimator.ofFloat(0.0f, 1.0f).apply {
interpolator = Interpolators.FAST_OUT_SLOW_IN
addUpdateListener {
updateTargetState()
applyState(animationStartState!!.interpolate(targetState!!, animatedFraction))
interpolateBounds(animationStartBounds, targetBounds, animatedFraction,
result = currentBounds)
applyState(currentBounds)
}
addListener(object : AnimatorListenerAdapter() {
private var cancelled: Boolean = false
override fun onAnimationCancel(animation: Animator?) {
cancelled = true
animationPending = false
rootView?.removeCallbacks(startAnimation)
}
override fun onAnimationEnd(animation: Animator?) {
if (!cancelled) {
applyTargetStateIfNotAnimating()
@@ -81,30 +92,41 @@ class MediaHierarchyManager @Inject constructor(
override fun onAnimationStart(animation: Animator?) {
cancelled = false
animationPending = false
}
})
}
private var targetState: MediaState? = null
private val mediaHosts = arrayOfNulls<MediaHost>(LOCATION_LOCKSCREEN + 1)
private val mediaHosts = arrayOfNulls<MediaHost>(LOCATION_LOCKSCREEN + 1)
/**
* The last location where this view was at before going to the desired location. This is
* useful for guided transitions.
*/
@MediaLocation private var previousLocation = -1
@MediaLocation
private var previousLocation = -1
/**
* The desired location where the view will be at the end of the transition.
*/
@MediaLocation private var desiredLocation = -1
@MediaLocation
private var desiredLocation = -1
/**
* The current attachment location where the view is currently attached.
* Usually this matches the desired location except for animations whenever a view moves
* to the new desired location, during which it is in [IN_OVERLAY].
*/
@MediaLocation private var currentAttachmentLocation = -1
@MediaLocation
private var currentAttachmentLocation = -1
/**
* Are we currently waiting on an animation to start?
*/
private var animationPending: Boolean = false
private val startAnimation: Runnable = Runnable { animator.start() }
/**
* The expansion of quick settings
*/
var qsExpansion: Float = 0.0f
set(value) {
if (field != value) {
@@ -117,6 +139,40 @@ class MediaHierarchyManager @Inject constructor(
}
}
/**
* Are location changes currently blocked?
*/
private val blockLocationChanges: Boolean
get() {
return goingToSleep || dozeAnimationRunning
}
/**
* Are we currently going to sleep
*/
private var goingToSleep: Boolean = false
set(value) {
if (field != value) {
field = value
if (!value) {
updateDesiredLocation()
}
}
}
/**
* Is the doze animation currently Running
*/
private var dozeAnimationRunning: Boolean = false
private set(value) {
if (field != value) {
field = value
if (!value) {
updateDesiredLocation()
}
}
}
init {
statusBarStateController.addCallback(object : StatusBarStateController.StateListener {
override fun onStatePreChange(oldState: Int, newState: Int) {
@@ -129,6 +185,34 @@ class MediaHierarchyManager @Inject constructor(
override fun onStateChanged(newState: Int) {
updateTargetState()
}
override fun onDozeAmountChanged(linear: Float, eased: Float) {
dozeAnimationRunning = linear != 0.0f && linear != 1.0f
}
override fun onDozingChanged(isDozing: Boolean) {
if (!isDozing) {
dozeAnimationRunning = false
}
}
})
wakefulnessLifecycle.addObserver(object : WakefulnessLifecycle.Observer {
override fun onFinishedGoingToSleep() {
goingToSleep = false
}
override fun onStartedGoingToSleep() {
goingToSleep = true
}
override fun onFinishedWakingUp() {
goingToSleep = false
}
override fun onStartedWakingUp() {
goingToSleep = false
}
})
}
@@ -138,8 +222,8 @@ class MediaHierarchyManager @Inject constructor(
*
* @return the hostView associated with this location
*/
fun register(mediaObject: MediaHost): ViewGroup {
val viewHost = createUniqueObjectHost(mediaObject)
fun register(mediaObject: MediaHost): UniqueObjectHostView {
val viewHost = createUniqueObjectHost()
mediaObject.hostView = viewHost
mediaHosts[mediaObject.location] = mediaObject
if (mediaObject.location == desiredLocation) {
@@ -154,22 +238,13 @@ class MediaHierarchyManager @Inject constructor(
return viewHost
}
private fun createUniqueObjectHost(host: MediaHost): UniqueObjectHostView {
private fun createUniqueObjectHost(): UniqueObjectHostView {
val viewHost = UniqueObjectHostView(context)
viewHost.measurementCache = mediaMeasurementProvider.obtainCache(host)
viewHost.onMeasureListener = { input ->
if (host.location == desiredLocation) {
// Measurement of the currently active player is happening, Let's make
// sure the player width is up to date
val measuringInput = host.getMeasuringInput(input)
mediaViewManager.setPlayerWidth(measuringInput.width)
}
}
viewHost.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(p0: View?) {
if (rootOverlay == null) {
rootOverlay = (viewHost.viewRootImpl.view.overlay as ViewGroupOverlay)
rootView = viewHost.viewRootImpl.view
rootOverlay = (rootView!!.overlay as ViewGroupOverlay)
}
viewHost.removeOnAttachStateChangeListener(this)
}
@@ -195,8 +270,9 @@ class MediaHierarchyManager @Inject constructor(
// Let's perform a transition
val animate = shouldAnimateTransition(desiredLocation, previousLocation)
val (animDuration, delay) = getAnimationParams(previousLocation, desiredLocation)
mediaViewManager.onDesiredLocationChanged(getHost(desiredLocation)?.currentState,
animate, animDuration, delay)
val host = getHost(desiredLocation)
mediaViewManager.onDesiredLocationChanged(desiredLocation, host, animate, animDuration,
delay)
performTransitionToNewLocation(isNewView, animate)
}
}
@@ -222,14 +298,18 @@ class MediaHierarchyManager @Inject constructor(
// Let's animate to the new position, starting from the current position
// We also go in here in case the view was detached, since the bounds wouldn't
// be correct anymore
animationStartState = currentState.copy()
animationStartBounds.set(currentBounds)
} else {
// otherwise, let's take the freshest state, since the current one could
// be outdated
animationStartState = previousHost.currentState.copy()
animationStartBounds.set(previousHost.currentBounds)
}
adjustAnimatorForTransition(desiredLocation, previousLocation)
animator.start()
rootView?.let {
// Let's delay the animation start until we finished laying out
animationPending = true
it.postOnAnimation(startAnimation)
}
} else {
cancelAnimationAndApplyDesiredState()
}
@@ -239,6 +319,9 @@ class MediaHierarchyManager @Inject constructor(
@MediaLocation currentLocation: Int,
@MediaLocation previousLocation: Int
): Boolean {
if (isCurrentlyInGuidedTransformation()) {
return false
}
if (currentLocation == LOCATION_QQS &&
previousLocation == LOCATION_LOCKSCREEN &&
(statusBarStateController.leaveOpenOnKeyguardHide() ||
@@ -247,7 +330,7 @@ class MediaHierarchyManager @Inject constructor(
// non-trivial reattaching logic happening that will make the view not-shown earlier
return true
}
return mediaCarousel.isShown || animator.isRunning
return mediaFrame.isShown || animator.isRunning || animationPending
}
private fun adjustAnimatorForTransition(desiredLocation: Int, previousLocation: Int) {
@@ -279,7 +362,7 @@ class MediaHierarchyManager @Inject constructor(
// Let's immediately apply the target state (which is interpolated) if there is
// no animation running. Otherwise the animation update will already update
// the location
applyState(targetState!!)
applyState(targetBounds)
}
}
@@ -291,14 +374,34 @@ class MediaHierarchyManager @Inject constructor(
val progress = getTransformationProgress()
val currentHost = getHost(desiredLocation)!!
val previousHost = getHost(previousLocation)!!
val newState = currentHost.currentState
val previousState = previousHost.currentState
targetState = previousState.interpolate(newState, progress)
val newBounds = currentHost.currentBounds
val previousBounds = previousHost.currentBounds
targetBounds = interpolateBounds(previousBounds, newBounds, progress)
} else {
targetState = getHost(desiredLocation)?.currentState
val bounds = getHost(desiredLocation)?.currentBounds ?: return
targetBounds.set(bounds)
}
}
private fun interpolateBounds(
startBounds: Rect,
endBounds: Rect,
progress: Float,
result: Rect? = null
): Rect {
val left = MathUtils.lerp(startBounds.left.toFloat(),
endBounds.left.toFloat(), progress).toInt()
val top = MathUtils.lerp(startBounds.top.toFloat(),
endBounds.top.toFloat(), progress).toInt()
val right = MathUtils.lerp(startBounds.right.toFloat(),
endBounds.right.toFloat(), progress).toInt()
val bottom = MathUtils.lerp(startBounds.bottom.toFloat(),
endBounds.bottom.toFloat(), progress).toInt()
val resultBounds = result ?: Rect()
resultBounds.set(left, top, right, bottom)
return resultBounds
}
/**
* @return true if this transformation is guided by an external progress like a finger
*/
@@ -339,21 +442,27 @@ class MediaHierarchyManager @Inject constructor(
private fun cancelAnimationAndApplyDesiredState() {
animator.cancel()
getHost(desiredLocation)?.let {
applyState(it.currentState)
applyState(it.currentBounds, immediately = true)
}
}
private fun applyState(state: MediaState) {
currentState = state.copy()
mediaViewManager.setCurrentState(currentState)
/**
* Apply the current state to the view, updating it's bounds and desired state
*/
private fun applyState(bounds: Rect, immediately: Boolean = false) {
currentBounds.set(bounds)
val currentlyInGuidedTransformation = isCurrentlyInGuidedTransformation()
val startLocation = if (currentlyInGuidedTransformation) previousLocation else -1
val progress = if (currentlyInGuidedTransformation) getTransformationProgress() else 1.0f
val endLocation = desiredLocation
mediaViewManager.setCurrentState(startLocation, endLocation, progress, immediately)
updateHostAttachment()
if (currentAttachmentLocation == IN_OVERLAY) {
val boundsOnScreen = state.boundsOnScreen
mediaCarousel.setLeftTopRightBottom(
boundsOnScreen.left,
boundsOnScreen.top,
boundsOnScreen.right,
boundsOnScreen.bottom)
mediaFrame.setLeftTopRightBottom(
currentBounds.left,
currentBounds.top,
currentBounds.right,
currentBounds.bottom)
}
}
@@ -364,26 +473,29 @@ class MediaHierarchyManager @Inject constructor(
currentAttachmentLocation = newLocation
// Remove the carousel from the old host
(mediaCarousel.parent as ViewGroup?)?.removeView(mediaCarousel)
(mediaFrame.parent as ViewGroup?)?.removeView(mediaFrame)
// Add it to the new one
val targetHost = getHost(desiredLocation)!!.hostView
if (inOverlay) {
rootOverlay!!.add(mediaCarousel)
rootOverlay!!.add(mediaFrame)
} else {
targetHost.addView(mediaCarousel)
mediaViewManager.onViewReattached()
targetHost.addView(mediaFrame)
}
}
}
private fun isTransitionRunning(): Boolean {
return isCurrentlyInGuidedTransformation() && getTransformationProgress() != 1.0f ||
animator.isRunning
animator.isRunning || animationPending
}
@MediaLocation
private fun calculateLocation(): Int {
if (blockLocationChanges) {
// Keep the current location until we're allowed to again
return desiredLocation
}
val onLockscreen = (!bypassController.bypassEnabled &&
(statusbarState == StatusBarState.KEYGUARD ||
statusbarState == StatusBarState.FULLSCREEN_USER_SWITCHER))
@@ -396,13 +508,6 @@ class MediaHierarchyManager @Inject constructor(
}
}
/**
* The expansion of quick settings
*/
@IntDef(prefix = ["LOCATION_"], value = [LOCATION_QS, LOCATION_QQS, LOCATION_LOCKSCREEN])
@Retention(AnnotationRetention.SOURCE)
annotation class MediaLocation
companion object {
/**
* Attached in expanded quick settings
@@ -425,3 +530,8 @@ class MediaHierarchyManager @Inject constructor(
const val IN_OVERLAY = -1000
}
}
@IntDef(prefix = ["LOCATION_"], value = [MediaHierarchyManager.LOCATION_QS,
MediaHierarchyManager.LOCATION_QQS, MediaHierarchyManager.LOCATION_LOCKSCREEN])
@Retention(AnnotationRetention.SOURCE)
annotation class MediaLocation

View File

@@ -1,20 +1,22 @@
package com.android.systemui.media
import android.graphics.Rect
import android.util.MathUtils
import android.view.View
import android.view.View.OnAttachStateChangeListener
import android.view.ViewGroup
import com.android.systemui.media.MediaHierarchyManager.MediaLocation
import com.android.systemui.util.animation.MeasurementInput
import com.android.systemui.util.animation.MeasurementOutput
import com.android.systemui.util.animation.UniqueObjectHostView
import java.util.Objects
import javax.inject.Inject
class MediaHost @Inject constructor(
private val state: MediaHostState,
private val state: MediaHostStateHolder,
private val mediaHierarchyManager: MediaHierarchyManager,
private val mediaDataManager: MediaDataManager
) : MediaState by state {
lateinit var hostView: ViewGroup
private val mediaDataManager: MediaDataManager,
private val mediaDataManagerCombineLatest: MediaDataCombineLatest,
private val mediaHostStatesManager: MediaHostStatesManager
) : MediaHostState by state {
lateinit var hostView: UniqueObjectHostView
var location: Int = -1
private set
var visibleChangedListener: ((Boolean) -> Unit)? = null
@@ -24,9 +26,9 @@ class MediaHost @Inject constructor(
private val tmpLocationOnScreen: IntArray = intArrayOf(0, 0)
/**
* Get the current Media state. This also updates the location on screen
* Get the current bounds on the screen. This makes sure the state is fresh and up to date
*/
val currentState: MediaState
val currentBounds: Rect = Rect()
get() {
hostView.getLocationOnScreen(tmpLocationOnScreen)
var left = tmpLocationOnScreen[0] + hostView.paddingLeft
@@ -43,8 +45,8 @@ class MediaHost @Inject constructor(
bottom = 0
top = 0
}
state.boundsOnScreen.set(left, top, right, bottom)
return state
field.set(left, top, right, bottom)
return field
}
private val listener = object : MediaDataManager.Listener {
@@ -59,6 +61,8 @@ class MediaHost @Inject constructor(
/**
* Initialize this MediaObject and create a host view.
* All state should already be set on this host before calling this method in order to avoid
* unnecessary state changes which lead to remeasurings later on.
*
* @param location the location this host name has. Used to identify the host during
* transitions.
@@ -68,14 +72,39 @@ class MediaHost @Inject constructor(
hostView = mediaHierarchyManager.register(this)
hostView.addOnAttachStateChangeListener(object : OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View?) {
mediaDataManager.addListener(listener)
// we should listen to the combined state change, since otherwise there might
// be a delay until the views and the controllers are initialized, leaving us
// with either a blank view or the controllers not yet initialized and the
// measuring wrong
mediaDataManagerCombineLatest.addListener(listener)
updateViewVisibility()
}
override fun onViewDetachedFromWindow(v: View?) {
mediaDataManager.removeListener(listener)
mediaDataManagerCombineLatest.removeListener(listener)
}
})
// Listen to measurement updates and update our state with it
hostView.measurementManager = object : UniqueObjectHostView.MeasurementManager {
override fun onMeasure(input: MeasurementInput): MeasurementOutput {
// Modify the measurement to exactly match the dimensions
if (View.MeasureSpec.getMode(input.widthMeasureSpec) == View.MeasureSpec.AT_MOST) {
input.widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(
View.MeasureSpec.getSize(input.widthMeasureSpec),
View.MeasureSpec.EXACTLY)
}
// This will trigger a state change that ensures that we now have a state available
state.measurementInput = input
return mediaHostStatesManager.getPlayerDimensions(state)
}
}
// Whenever the state changes, let our state manager know
state.changedListener = {
mediaHostStatesManager.updateHostState(location, state)
}
updateViewVisibility()
}
@@ -89,71 +118,93 @@ class MediaHost @Inject constructor(
visibleChangedListener?.invoke(visible)
}
class MediaHostState @Inject constructor() : MediaState {
var measurementInput: MediaMeasurementInput? = null
override var expansion: Float = 0.0f
override var showsOnlyActiveMedia: Boolean = false
override val boundsOnScreen: Rect = Rect()
class MediaHostStateHolder @Inject constructor() : MediaHostState {
override fun copy(): MediaState {
val mediaHostState = MediaHostState()
override var measurementInput: MeasurementInput? = null
set(value) {
if (value?.equals(field) != true) {
field = value
changedListener?.invoke()
}
}
override var expansion: Float = 0.0f
set(value) {
if (!value.equals(field)) {
field = value
changedListener?.invoke()
}
}
override var showsOnlyActiveMedia: Boolean = false
set(value) {
if (!value.equals(field)) {
field = value
changedListener?.invoke()
}
}
/**
* A listener for all changes. This won't be copied over when invoking [copy]
*/
var changedListener: (() -> Unit)? = null
/**
* Get a copy of this state. This won't copy any listeners it may have set
*/
override fun copy(): MediaHostState {
val mediaHostState = MediaHostStateHolder()
mediaHostState.expansion = expansion
mediaHostState.showsOnlyActiveMedia = showsOnlyActiveMedia
mediaHostState.boundsOnScreen.set(boundsOnScreen)
mediaHostState.measurementInput = measurementInput
mediaHostState.measurementInput = measurementInput?.copy()
return mediaHostState
}
override fun interpolate(other: MediaState, amount: Float): MediaState {
val result = MediaHostState()
result.expansion = MathUtils.lerp(expansion, other.expansion, amount)
val left = MathUtils.lerp(boundsOnScreen.left.toFloat(),
other.boundsOnScreen.left.toFloat(), amount).toInt()
val top = MathUtils.lerp(boundsOnScreen.top.toFloat(),
other.boundsOnScreen.top.toFloat(), amount).toInt()
val right = MathUtils.lerp(boundsOnScreen.right.toFloat(),
other.boundsOnScreen.right.toFloat(), amount).toInt()
val bottom = MathUtils.lerp(boundsOnScreen.bottom.toFloat(),
other.boundsOnScreen.bottom.toFloat(), amount).toInt()
result.boundsOnScreen.set(left, top, right, bottom)
result.showsOnlyActiveMedia = other.showsOnlyActiveMedia || showsOnlyActiveMedia
if (amount > 0.0f) {
if (other is MediaHostState) {
result.measurementInput = other.measurementInput
}
} else {
result.measurementInput
override fun equals(other: Any?): Boolean {
if (!(other is MediaHostState)) {
return false
}
if (!Objects.equals(measurementInput, other.measurementInput)) {
return false
}
if (expansion != other.expansion) {
return false
}
if (showsOnlyActiveMedia != other.showsOnlyActiveMedia) {
return false
}
return true
}
override fun hashCode(): Int {
var result = measurementInput?.hashCode() ?: 0
result = 31 * result + expansion.hashCode()
result = 31 * result + showsOnlyActiveMedia.hashCode()
return result
}
override fun getMeasuringInput(input: MeasurementInput): MediaMeasurementInput {
measurementInput = MediaMeasurementInput(input, expansion)
return measurementInput as MediaMeasurementInput
}
}
}
interface MediaState {
interface MediaHostState {
/**
* The last measurement input that this state was measured with. Infers with and height of
* the players.
*/
var measurementInput: MeasurementInput?
/**
* The expansion of the player, 0 for fully collapsed, 1 for fully expanded
*/
var expansion: Float
var showsOnlyActiveMedia: Boolean
val boundsOnScreen: Rect
fun copy(): MediaState
fun interpolate(other: MediaState, amount: Float): MediaState
fun getMeasuringInput(input: MeasurementInput): MediaMeasurementInput
}
/**
* The measurement input for a Media View
*/
data class MediaMeasurementInput(
private val viewInput: MeasurementInput,
val expansion: Float
) : MeasurementInput by viewInput {
override fun sameAs(input: MeasurementInput?): Boolean {
if (!(input is MediaMeasurementInput)) {
return false
}
return width == input.width && expansion == input.expansion
}
/**
* Is this host only showing active media or is it showing all of them including resumption?
*/
var showsOnlyActiveMedia: Boolean
/**
* Get a copy of this view state, deepcopying all appropriate members
*/
fun copy(): MediaHostState
}

View File

@@ -0,0 +1,116 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.media
import com.android.systemui.util.animation.MeasurementOutput
import javax.inject.Inject
import javax.inject.Singleton
/**
* A class responsible for managing all media host states of the various host locations and
* coordinating the heights among different players. This class can be used to get the most up to
* date state for any location.
*/
@Singleton
class MediaHostStatesManager @Inject constructor() {
private val callbacks: MutableSet<Callback> = mutableSetOf()
private val controllers: MutableSet<MediaViewController> = mutableSetOf()
/**
* A map with all media states of all locations.
*/
val mediaHostStates: MutableMap<Int, MediaHostState> = mutableMapOf()
/**
* Notify that a media state for a given location has changed. Should only be called from
* Media hosts themselves.
*/
fun updateHostState(@MediaLocation location: Int, hostState: MediaHostState) {
val currentState = mediaHostStates.get(location)
if (!hostState.equals(currentState)) {
val newState = hostState.copy()
mediaHostStates.put(location, newState)
// First update all the controllers to ensure they get the chance to measure
for (controller in controllers) {
controller.stateCallback.onHostStateChanged(location, newState)
}
// Then update all other callbacks which may depend on the controllers above
for (callback in callbacks) {
callback.onHostStateChanged(location, newState)
}
}
}
/**
* Get the dimensions of all players combined, which determines the overall height of the
* media carousel and the media hosts.
*/
fun getPlayerDimensions(hostState: MediaHostState): MeasurementOutput {
val result = MeasurementOutput(0, 0)
for (controller in controllers) {
val measurement = controller.getMeasurementsForState(hostState)
measurement?.let {
if (it.measuredHeight > result.measuredHeight) {
result.measuredHeight = it.measuredHeight
}
if (it.measuredWidth > result.measuredWidth) {
result.measuredWidth = it.measuredWidth
}
}
}
return result
}
/**
* Add a callback to be called when a MediaState has updated
*/
fun addCallback(callback: Callback) {
callbacks.add(callback)
}
/**
* Remove a callback that listens to media states
*/
fun removeCallback(callback: Callback) {
callbacks.remove(callback)
}
/**
* Register a controller that listens to media states and is used to determine the size of
* the media carousel
*/
fun addController(controller: MediaViewController) {
controllers.add(controller)
}
/**
* Notify the manager about the removal of a controller.
*/
fun removeController(controller: MediaViewController) {
controllers.remove(controller)
}
interface Callback {
/**
* Notify the callbacks that a media state for a host has changed, and that the
* corresponding view states should be updated and applied
*/
fun onHostStateChanged(@MediaLocation location: Int, mediaHostState: MediaHostState)
}
}

View File

@@ -1,59 +0,0 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package com.android.systemui.media
import com.android.systemui.util.animation.BaseMeasurementCache
import com.android.systemui.util.animation.GuaranteedMeasurementCache
import com.android.systemui.util.animation.MeasurementCache
import com.android.systemui.util.animation.MeasurementInput
import com.android.systemui.util.animation.MeasurementOutput
import javax.inject.Inject
import javax.inject.Singleton
/**
* A class responsible creating measurement caches for media hosts which also coordinates with
* the view manager to obtain sizes for unknown measurement inputs.
*/
@Singleton
class MediaMeasurementManager @Inject constructor(
private val mediaViewManager: MediaViewManager
) {
private val baseCache: MeasurementCache
init {
baseCache = BaseMeasurementCache()
}
private fun provideMeasurement(input: MediaMeasurementInput) : MeasurementOutput? {
return mediaViewManager.obtainMeasurement(input)
}
/**
* Obtain a guaranteed measurement cache for a host view. The measurement cache makes sure that
* requesting any size from the cache will always return the correct value.
*/
fun obtainCache(host: MediaState): GuaranteedMeasurementCache {
val remapper = { input: MeasurementInput ->
host.getMeasuringInput(input)
}
val provider = { input: MeasurementInput ->
provideMeasurement(input as MediaMeasurementInput)
}
return GuaranteedMeasurementCache(baseCache, remapper, provider)
}
}

View File

@@ -0,0 +1,280 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package com.android.systemui.media
import android.content.Context
import androidx.constraintlayout.widget.ConstraintSet
import com.android.systemui.R
import com.android.systemui.util.animation.TransitionLayout
import com.android.systemui.util.animation.TransitionLayoutController
import com.android.systemui.util.animation.TransitionViewState
import com.android.systemui.util.animation.MeasurementOutput
/**
* A class responsible for controlling a single instance of a media player handling interactions
* with the view instance and keeping the media view states up to date.
*/
class MediaViewController(
context: Context,
val mediaHostStatesManager: MediaHostStatesManager
) {
private var firstRefresh: Boolean = true
private var transitionLayout: TransitionLayout? = null
private val layoutController = TransitionLayoutController()
private var animationDelay: Long = 0
private var animationDuration: Long = 0
private var animateNextStateChange: Boolean = false
private val measurement = MeasurementOutput(0, 0)
/**
* A map containing all viewStates for all locations of this mediaState
*/
private val mViewStates: MutableMap<MediaHostState, TransitionViewState?> = mutableMapOf()
/**
* The ending location of the view where it ends when all animations and transitions have
* finished
*/
private var currentEndLocation: Int = -1
/**
* The ending location of the view where it ends when all animations and transitions have
* finished
*/
private var currentStartLocation: Int = -1
/**
* The progress of the transition or 1.0 if there is no transition happening
*/
private var currentTransitionProgress: Float = 1.0f
/**
* A temporary state used to store intermediate measurements.
*/
private val tmpState = TransitionViewState()
/**
* A callback for media state changes
*/
val stateCallback = object : MediaHostStatesManager.Callback {
override fun onHostStateChanged(location: Int, mediaHostState: MediaHostState) {
if (location == currentEndLocation || location == currentStartLocation) {
setCurrentState(currentStartLocation,
currentEndLocation,
currentTransitionProgress,
applyImmediately = false)
}
}
}
/**
* The expanded constraint set used to render a expanded player. If it is modified, make sure
* to call [refreshState]
*/
val collapsedLayout = ConstraintSet()
/**
* The expanded constraint set used to render a collapsed player. If it is modified, make sure
* to call [refreshState]
*/
val expandedLayout = ConstraintSet()
init {
collapsedLayout.load(context, R.xml.media_collapsed)
expandedLayout.load(context, R.xml.media_expanded)
mediaHostStatesManager.addController(this)
}
/**
* Notify this controller that the view has been removed and all listeners should be destroyed
*/
fun onDestroy() {
mediaHostStatesManager.removeController(this)
}
private fun ensureAllMeasurements() {
val mediaStates = mediaHostStatesManager.mediaHostStates
for (entry in mediaStates) {
obtainViewState(entry.value)
}
}
/**
* Get the constraintSet for a given expansion
*/
private fun constraintSetForExpansion(expansion: Float): ConstraintSet =
if (expansion > 0) expandedLayout else collapsedLayout
/**
* Obtain a new viewState for a given media state. This usually returns a cached state, but if
* it's not available, it will recreate one by measuring, which may be expensive.
*/
private fun obtainViewState(state: MediaHostState): TransitionViewState? {
val viewState = mViewStates[state]
if (viewState != null) {
// we already have cached this measurement, let's continue
return viewState
}
val result: TransitionViewState?
if (transitionLayout != null && state.measurementInput != null) {
// Let's create a new measurement
if (state.expansion == 0.0f || state.expansion == 1.0f) {
result = transitionLayout!!.calculateViewState(
state.measurementInput!!,
constraintSetForExpansion(state.expansion),
TransitionViewState())
// We don't want to cache interpolated or null states as this could quickly fill up
// our cache. We only cache the start and the end states since the interpolation
// is cheap
mViewStates[state.copy()] = result
} else {
// This is an interpolated state
val startState = state.copy().also { it.expansion = 0.0f }
// Given that we have a measurement and a view, let's get (guaranteed) viewstates
// from the start and end state and interpolate them
val startViewState = obtainViewState(startState) as TransitionViewState
val endState = state.copy().also { it.expansion = 1.0f }
val endViewState = obtainViewState(endState) as TransitionViewState
result = TransitionViewState()
layoutController.getInterpolatedState(
startViewState,
endViewState,
state.expansion,
result)
}
} else {
result = null
}
return result
}
/**
* Attach a view to this controller. This may perform measurements if it's not available yet
* and should therefore be done carefully.
*/
fun attach(transitionLayout: TransitionLayout) {
this.transitionLayout = transitionLayout
layoutController.attach(transitionLayout)
ensureAllMeasurements()
if (currentEndLocation == -1) {
return
}
// Set the previously set state immediately to the view, now that it's finally attached
setCurrentState(
startLocation = currentStartLocation,
endLocation = currentEndLocation,
transitionProgress = currentTransitionProgress,
applyImmediately = true)
}
/**
* Obtain a measurement for a given location. This makes sure that the state is up to date
* and all widgets know their location. Calling this method may create a measurement if we
* don't have a cached value available already.
*/
fun getMeasurementsForState(hostState: MediaHostState): MeasurementOutput? {
val viewState = obtainViewState(hostState) ?: return null
measurement.measuredWidth = viewState.width
measurement.measuredHeight = viewState.height
return measurement
}
/**
* Set a new state for the controlled view which can be an interpolation between multiple
* locations.
*/
fun setCurrentState(
@MediaLocation startLocation: Int,
@MediaLocation endLocation: Int,
transitionProgress: Float,
applyImmediately: Boolean
) {
currentEndLocation = endLocation
currentStartLocation = startLocation
currentTransitionProgress = transitionProgress
val shouldAnimate = animateNextStateChange && !applyImmediately
// Obtain the view state that we'd want to be at the end
// The view might not be bound yet or has never been measured and in that case will be
// reset once the state is fully available
val endState = obtainViewStateForLocation(endLocation) ?: return
layoutController.setMeasureState(endState)
// If the view isn't bound, we can drop the animation, otherwise we'll executute it
animateNextStateChange = false
if (transitionLayout == null) {
return
}
val startState = obtainViewStateForLocation(startLocation)
val result: TransitionViewState?
if (transitionProgress == 1.0f || startState == null) {
result = endState
} else if (transitionProgress == 0.0f) {
result = startState
} else {
layoutController.getInterpolatedState(startState, endState, transitionProgress,
tmpState)
result = tmpState
}
layoutController.setState(result, applyImmediately, shouldAnimate, animationDuration,
animationDelay)
}
private fun obtainViewStateForLocation(location: Int): TransitionViewState? {
val mediaState = mediaHostStatesManager.mediaHostStates[location] ?: return null
return obtainViewState(mediaState)
}
/**
* Notify that the location is changing right now and a [setCurrentState] change is imminent.
* This updates the width the view will me measured with.
*/
fun onLocationPreChange(@MediaLocation newLocation: Int) {
val viewState = obtainViewStateForLocation(newLocation)
viewState?.let {
layoutController.setMeasureState(it)
}
}
/**
* Request that the next state change should be animated with the given parameters.
*/
fun animatePendingStateChange(duration: Long, delay: Long) {
animateNextStateChange = true
animationDuration = duration
animationDelay = delay
}
/**
* Clear all existing measurements and refresh the state to match the view.
*/
fun refreshState() {
if (!firstRefresh) {
// Let's clear all of our measurements and recreate them!
mViewStates.clear()
setCurrentState(currentStartLocation, currentEndLocation, currentTransitionProgress,
applyImmediately = false)
}
firstRefresh = false
}
}

View File

@@ -16,8 +16,8 @@ import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.plugins.ActivityStarter
import com.android.systemui.qs.PageIndicator
import com.android.systemui.statusbar.notification.VisualStabilityManager
import com.android.systemui.util.animation.MeasurementOutput
import com.android.systemui.util.animation.UniqueObjectHostView
import com.android.systemui.util.animation.requiresRemeasuring
import com.android.systemui.util.concurrency.DelayableExecutor
import java.util.concurrent.Executor
import javax.inject.Inject
@@ -36,16 +36,51 @@ class MediaViewManager @Inject constructor(
@Background private val backgroundExecutor: DelayableExecutor,
private val visualStabilityManager: VisualStabilityManager,
private val activityStarter: ActivityStarter,
private val mediaHostStatesManager: MediaHostStatesManager,
mediaManager: MediaDataCombineLatest
) {
private var playerWidth: Int = 0
/**
* The desired location where we'll be at the end of the transformation. Usually this matches
* the end location, except when we're still waiting on a state update call.
*/
@MediaLocation
private var desiredLocation: Int = -1
/**
* The ending location of the view where it ends when all animations and transitions have
* finished
*/
@MediaLocation
private var currentEndLocation: Int = -1
/**
* The ending location of the view where it ends when all animations and transitions have
* finished
*/
@MediaLocation
private var currentStartLocation: Int = -1
/**
* The progress of the transition or 1.0 if there is no transition happening
*/
private var currentTransitionProgress: Float = 1.0f
/**
* The measured width of the carousel
*/
private var carouselMeasureWidth: Int = 0
/**
* The measured height of the carousel
*/
private var carouselMeasureHeight: Int = 0
private var playerWidthPlusPadding: Int = 0
private var desiredState: MediaHost.MediaHostState? = null
private var currentState: MediaState? = null
private var desiredHostState: MediaHostState? = null
private val mediaCarousel: HorizontalScrollView
val mediaFrame: ViewGroup
val mediaPlayers: MutableMap<String, MediaControlPanel> = mutableMapOf()
private val mediaContent: ViewGroup
private val mediaPlayers: MutableMap<String, MediaControlPanel> = mutableMapOf()
private val pageIndicator: PageIndicator
private val gestureDetector: GestureDetectorCompat
private val visualStabilityCallback: VisualStabilityManager.Callback
@@ -115,6 +150,7 @@ class MediaViewManager @Inject constructor(
override fun onMediaDataLoaded(key: String, data: MediaData) {
updateView(key, data)
updatePlayerVisibilities()
mediaCarousel.requiresRemeasuring = true
}
override fun onMediaDataRemoved(key: String) {
@@ -137,6 +173,13 @@ class MediaViewManager @Inject constructor(
}
}
})
mediaHostStatesManager.addCallback(object : MediaHostStatesManager.Callback {
override fun onHostStateChanged(location: Int, mediaHostState: MediaHostState) {
if (location == desiredLocation) {
onDesiredLocationChanged(desiredLocation, mediaHostState, animate = false)
}
}
})
}
private fun inflateMediaCarousel(): ViewGroup {
@@ -220,7 +263,7 @@ class MediaViewManager @Inject constructor(
var existingPlayer = mediaPlayers[key]
if (existingPlayer == null) {
existingPlayer = MediaControlPanel(context, foregroundExecutor, backgroundExecutor,
activityStarter)
activityStarter, mediaHostStatesManager)
existingPlayer.attach(PlayerViewHolder.create(LayoutInflater.from(context),
mediaContent))
mediaPlayers[key] = existingPlayer
@@ -228,14 +271,14 @@ class MediaViewManager @Inject constructor(
ViewGroup.LayoutParams.WRAP_CONTENT)
existingPlayer.view?.player?.setLayoutParams(lp)
existingPlayer.setListening(currentlyExpanded)
updatePlayerToState(existingPlayer, noAnimation = true)
if (existingPlayer.isPlaying) {
mediaContent.addView(existingPlayer.view?.player, 0)
} else {
mediaContent.addView(existingPlayer.view?.player)
}
updatePlayerToCurrentState(existingPlayer)
} else if (existingPlayer.isPlaying &&
mediaContent.indexOfChild(existingPlayer.view?.player) != 0) {
mediaContent.indexOfChild(existingPlayer.view?.player) != 0) {
if (visualStabilityManager.isReorderingAllowed) {
mediaContent.removeView(existingPlayer.view?.player)
mediaContent.addView(existingPlayer.view?.player, 0)
@@ -244,20 +287,10 @@ class MediaViewManager @Inject constructor(
}
}
existingPlayer.bind(data)
// Resetting the progress to make sure it's taken into account for the latest
// motion model
existingPlayer.view?.player?.progress = currentState?.expansion ?: 0.0f
updateMediaPaddings()
updatePageIndicator()
}
private fun updatePlayerToCurrentState(existingPlayer: MediaControlPanel) {
if (desiredState != null && desiredState!!.measurementInput != null) {
// make sure the player width is set to the current state
existingPlayer.setPlayerWidth(playerWidth)
}
}
private fun updateMediaPaddings() {
val padding = context.resources.getDimensionPixelSize(R.dimen.qs_media_padding)
val childCount = mediaContent.childCount
@@ -281,117 +314,101 @@ class MediaViewManager @Inject constructor(
}
/**
* Set the current state of a view. This is updated often during animations and we shouldn't
* do anything expensive.
* Set a new interpolated state for all players. This is a state that is usually controlled
* by a finger movement where the user drags from one state to the next.
*/
fun setCurrentState(state: MediaState) {
currentState = state
currentlyExpanded = state.expansion > 0
fun setCurrentState(
@MediaLocation startLocation: Int,
@MediaLocation endLocation: Int,
progress: Float,
immediately: Boolean
) {
// Hack: Since the indicator doesn't move with the player expansion, just make it disappear
// and then reappear at the end.
pageIndicator.alpha = if (state.expansion == 1f || state.expansion == 0f) 1f else 0f
for (mediaPlayer in mediaPlayers.values) {
val view = mediaPlayer.view?.player
view?.progress = state.expansion
pageIndicator.alpha = if (progress == 1f || progress == 0f) 1f else 0f
if (startLocation != currentStartLocation ||
endLocation != currentEndLocation ||
progress != currentTransitionProgress ||
immediately
) {
currentStartLocation = startLocation
currentEndLocation = endLocation
currentTransitionProgress = progress
for (mediaPlayer in mediaPlayers.values) {
updatePlayerToState(mediaPlayer, immediately)
}
}
}
private fun updatePlayerToState(mediaPlayer: MediaControlPanel, noAnimation: Boolean) {
mediaPlayer.mediaViewController.setCurrentState(
startLocation = currentStartLocation,
endLocation = currentEndLocation,
transitionProgress = currentTransitionProgress,
applyImmediately = noAnimation)
}
/**
* The desired location of this view has changed. We should remeasure the view to match
* the new bounds and kick off bounds animations if necessary.
* If an animation is happening, an animation is kicked of externally, which sets a new
* current state until we reach the targetState.
*
* @param desiredState the target state we're transitioning to
* @param desiredLocation the location we're going to
* @param desiredHostState the target state we're transitioning to
* @param animate should this be animated
*/
fun onDesiredLocationChanged(
desiredState: MediaState?,
desiredLocation: Int,
desiredHostState: MediaHostState?,
animate: Boolean,
duration: Long,
startDelay: Long
duration: Long = 200,
startDelay: Long = 0
) {
if (desiredState is MediaHost.MediaHostState) {
desiredHostState?.let {
// This is a hosting view, let's remeasure our players
this.desiredState = desiredState
val width = desiredState.boundsOnScreen.width()
if (playerWidth != width) {
setPlayerWidth(width)
for (mediaPlayer in mediaPlayers.values) {
if (animate && mediaPlayer.view?.player?.visibility == View.VISIBLE) {
mediaPlayer.animatePendingSizeChange(duration, startDelay)
}
}
val widthSpec = desiredState.measurementInput?.widthMeasureSpec ?: 0
val heightSpec = desiredState.measurementInput?.heightMeasureSpec ?: 0
var left = 0
for (i in 0 until mediaContent.childCount) {
val view = mediaContent.getChildAt(i)
view.measure(widthSpec, heightSpec)
view.layout(left, 0, left + width, view.measuredHeight)
left = left + playerWidthPlusPadding
}
}
}
}
fun setPlayerWidth(width: Int) {
if (width != playerWidth) {
playerWidth = width
playerWidthPlusPadding = playerWidth + context.resources.getDimensionPixelSize(
R.dimen.qs_media_padding)
this.desiredLocation = desiredLocation
this.desiredHostState = it
currentlyExpanded = it.expansion > 0
for (mediaPlayer in mediaPlayers.values) {
mediaPlayer.setPlayerWidth(width)
if (animate) {
mediaPlayer.mediaViewController.animatePendingStateChange(
duration = duration,
delay = startDelay)
}
mediaPlayer.mediaViewController.onLocationPreChange(desiredLocation)
}
// The player width has changed, let's update the scroll position to make sure
// it's still at the same place
var newScroll = activeMediaIndex * playerWidthPlusPadding
if (scrollIntoCurrentMedia > playerWidthPlusPadding) {
newScroll += playerWidthPlusPadding
- (scrollIntoCurrentMedia - playerWidthPlusPadding)
} else {
newScroll += scrollIntoCurrentMedia
}
mediaCarousel.scrollX = newScroll
updateCarouselSize()
}
}
/**
* Get a measurement for the given input state. This measures the first player and returns
* its bounds as if it were measured with the given measurement dimensions
* Update the size of the carousel, remeasuring it if necessary.
*/
fun obtainMeasurement(input: MediaMeasurementInput): MeasurementOutput? {
val firstPlayer = mediaPlayers.values.firstOrNull() ?: return null
var result: MeasurementOutput? = null
firstPlayer.view?.player?.let {
// Let's measure the size of the first player and return its height
val previousProgress = it.progress
val previousRight = it.right
val previousBottom = it.bottom
it.progress = input.expansion
firstPlayer.measure(input)
// Relayouting is necessary in motionlayout to obtain its size properly ....
it.layout(0, 0, it.measuredWidth, it.measuredHeight)
result = MeasurementOutput(it.measuredWidth, it.measuredHeight)
it.progress = previousProgress
if (desiredState != null) {
// remeasure it to the old size again!
firstPlayer.measure(desiredState!!.measurementInput)
it.layout(0, 0, previousRight, previousBottom)
}
}
return result
}
fun onViewReattached() {
if (desiredState is MediaHost.MediaHostState) {
// HACK: MotionLayout doesn't always properly reevalate the state, let's kick of
// a measure to force it.
val widthSpec = desiredState!!.measurementInput?.widthMeasureSpec ?: 0
val heightSpec = desiredState!!.measurementInput?.heightMeasureSpec ?: 0
for (mediaPlayer in mediaPlayers.values) {
mediaPlayer.view?.player?.measure(widthSpec, heightSpec)
private fun updateCarouselSize() {
val width = desiredHostState?.measurementInput?.width ?: 0
val height = desiredHostState?.measurementInput?.height ?: 0
if (width != carouselMeasureWidth && width != 0 ||
height != carouselMeasureWidth && height != 0) {
carouselMeasureWidth = width
carouselMeasureHeight = height
playerWidthPlusPadding = carouselMeasureWidth + context.resources.getDimensionPixelSize(
R.dimen.qs_media_padding)
// The player width has changed, let's update the scroll position to make sure
// it's still at the same place
var newScroll = activeMediaIndex * playerWidthPlusPadding
if (scrollIntoCurrentMedia > playerWidthPlusPadding) {
newScroll += playerWidthPlusPadding -
(scrollIntoCurrentMedia - playerWidthPlusPadding)
} else {
newScroll += scrollIntoCurrentMedia
}
mediaCarousel.scrollX = newScroll
// Let's remeasure the carousel
val widthSpec = desiredHostState?.measurementInput?.widthMeasureSpec ?: 0
val heightSpec = desiredHostState?.measurementInput?.heightMeasureSpec ?: 0
mediaCarousel.measure(widthSpec, heightSpec)
mediaCarousel.layout(0, 0, width, mediaCarousel.measuredHeight)
}
}
}

View File

@@ -23,18 +23,15 @@ import android.widget.ImageButton
import android.widget.ImageView
import android.widget.SeekBar
import android.widget.TextView
import androidx.constraintlayout.motion.widget.MotionLayout
import com.android.systemui.R
import com.android.systemui.util.animation.TransitionLayout
/**
* ViewHolder for a media player.
*/
class PlayerViewHolder private constructor(itemView: View) {
val player = itemView as MotionLayout
val background = itemView.requireViewById<View>(R.id.media_background)
val player = itemView as TransitionLayout
// Player information
val appIcon = itemView.requireViewById<ImageView>(R.id.icon)
@@ -61,7 +58,7 @@ class PlayerViewHolder private constructor(itemView: View) {
val action4 = itemView.requireViewById<ImageButton>(R.id.action4)
init {
(background.background as IlluminationDrawable).let {
(player.background as IlluminationDrawable).let {
it.setupTouch(seamless, player)
it.setupTouch(action0, player)
it.setupTouch(action1, player)
@@ -95,7 +92,7 @@ class PlayerViewHolder private constructor(itemView: View) {
* @param parent Parent of inflated view.
*/
@JvmStatic fun create(inflater: LayoutInflater, parent: ViewGroup): PlayerViewHolder {
val v = inflater.inflate(R.layout.qs_media_panel, parent, false)
val v = inflater.inflate(R.layout.media_view, parent, false)
return PlayerViewHolder(v)
}
}

View File

@@ -427,7 +427,7 @@ public class QSFragment extends LifecycleFragment implements QS, CommandQueue.Ca
(ViewGroup.MarginLayoutParams) hostView.getLayoutParams();
float targetPosition = absoluteBottomPosition - params.bottomMargin
- hostView.getHeight();
float currentPosition = mediaHost.getCurrentState().getBoundsOnScreen().top
float currentPosition = mediaHost.getCurrentBounds().top
- hostView.getTranslationY();
hostView.setTranslationY(targetPosition - currentPosition);
} else {

View File

@@ -187,9 +187,9 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne
}
protected void addMediaHostView() {
mMediaHost.init(MediaHierarchyManager.LOCATION_QS);
mMediaHost.setExpansion(1.0f);
mMediaHost.setShowsOnlyActiveMedia(false);
mMediaHost.init(MediaHierarchyManager.LOCATION_QS);
ViewGroup hostView = mMediaHost.getHostView();
addView(hostView);
int sidePaddings = getResources().getDimensionPixelSize(

View File

@@ -190,9 +190,9 @@ public class QuickQSPanel extends QSPanel {
switchTileLayout();
return null;
});
mMediaHost.init(MediaHierarchyManager.LOCATION_QQS);
mMediaHost.setExpansion(0.0f);
mMediaHost.setShowsOnlyActiveMedia(true);
mMediaHost.init(MediaHierarchyManager.LOCATION_QQS);
reAttachMediaHost();
}

View File

@@ -133,7 +133,7 @@ public class CrossFadeHelper {
}
public static void fadeIn(View view, float fadeInAmount) {
fadeIn(view, fadeInAmount, true /* remap */);
fadeIn(view, fadeInAmount, false /* remap */);
}
/**

View File

@@ -66,7 +66,7 @@ public abstract class CustomInterpolatorTransformation
return false;
}
View view = ownState.getTransformedView();
CrossFadeHelper.fadeIn(view, transformationAmount);
CrossFadeHelper.fadeIn(view, transformationAmount, true /* remap */);
ownState.transformViewFullyFrom(otherState, this, transformationAmount);
otherState.recycle();
return true;

View File

@@ -95,7 +95,7 @@ public class TransformState {
if (sameAs(otherState)) {
ensureVisible();
} else {
CrossFadeHelper.fadeIn(mTransformedView, transformationAmount);
CrossFadeHelper.fadeIn(mTransformedView, transformationAmount, true /* remap */);
}
transformViewFullyFrom(otherState, transformationAmount);
}
@@ -424,7 +424,7 @@ public class TransformState {
if (transformationAmount == 0.0f) {
prepareFadeIn();
}
CrossFadeHelper.fadeIn(mTransformedView, transformationAmount);
CrossFadeHelper.fadeIn(mTransformedView, transformationAmount, true /* remap */);
}
public void disappear(float transformationAmount, TransformableView otherView) {

View File

@@ -92,7 +92,7 @@ public class HybridNotificationView extends AlphaOptimizedLinearLayout
// We want to transform from the same y location as the title
TransformState otherState = notification.getCurrentState(
TRANSFORMING_VIEW_TITLE);
CrossFadeHelper.fadeIn(mTextView, transformationAmount);
CrossFadeHelper.fadeIn(mTextView, transformationAmount, true /* remap */);
if (otherState != null) {
ownState.transformViewVerticalFrom(otherState, transformationAmount);
otherState.recycle();

View File

@@ -23,7 +23,6 @@ import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.Color;
import android.graphics.PorterDuffColorFilter;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.service.notification.StatusBarNotification;
import android.util.ArraySet;
@@ -107,7 +106,7 @@ public class NotificationTemplateViewWrapper extends NotificationHeaderViewWrapp
TransformState otherState = notification.getCurrentState(
TRANSFORMING_VIEW_TITLE);
final View text = ownState.getTransformedView();
CrossFadeHelper.fadeIn(text, transformationAmount);
CrossFadeHelper.fadeIn(text, transformationAmount, true /* remap */);
if (otherState != null) {
ownState.transformViewVerticalFrom(otherState, this,
transformationAmount);

View File

@@ -284,7 +284,7 @@ public abstract class NotificationViewWrapper implements TransformableView {
@Override
public void transformFrom(TransformableView notification, float transformationAmount) {
CrossFadeHelper.fadeIn(mView, transformationAmount);
CrossFadeHelper.fadeIn(mView, transformationAmount, true /* remap */);
}
@Override

View File

@@ -1,87 +0,0 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package com.android.systemui.util.animation
/**
* A class responsible for caching view Measurements which guarantees that we always obtain a value
*/
class GuaranteedMeasurementCache constructor(
private val baseCache : MeasurementCache,
private val inputMapper: (MeasurementInput) -> MeasurementInput,
private val measurementProvider: (MeasurementInput) -> MeasurementOutput?
) : MeasurementCache {
override fun obtainMeasurement(input: MeasurementInput) : MeasurementOutput {
val mappedInput = inputMapper.invoke(input)
if (!baseCache.contains(mappedInput)) {
var measurement = measurementProvider.invoke(mappedInput)
if (measurement != null) {
// Only cache measurings that actually have a size
baseCache.putMeasurement(mappedInput, measurement)
} else {
measurement = MeasurementOutput(0, 0)
}
return measurement
} else {
return baseCache.obtainMeasurement(mappedInput)
}
}
override fun contains(input: MeasurementInput): Boolean {
return baseCache.contains(inputMapper.invoke(input))
}
override fun putMeasurement(input: MeasurementInput, output: MeasurementOutput) {
if (output.measuredWidth == 0 || output.measuredHeight == 0) {
// Only cache measurings that actually have a size
return;
}
val remappedInput = inputMapper.invoke(input)
baseCache.putMeasurement(remappedInput, output)
}
}
/**
* A base implementation class responsible for caching view Measurements
*/
class BaseMeasurementCache : MeasurementCache {
private val dataCache: MutableMap<MeasurementInput, MeasurementOutput> = mutableMapOf()
override fun obtainMeasurement(input: MeasurementInput) : MeasurementOutput {
val measurementOutput = dataCache[input]
if (measurementOutput == null) {
return MeasurementOutput(0, 0)
} else {
return measurementOutput
}
}
override fun contains(input: MeasurementInput) : Boolean {
return dataCache[input] != null
}
override fun putMeasurement(input: MeasurementInput, output: MeasurementOutput) {
dataCache[input] = output
}
}
interface MeasurementCache {
fun obtainMeasurement(input: MeasurementInput) : MeasurementOutput
fun contains(input: MeasurementInput) : Boolean
fun putMeasurement(input: MeasurementInput, output: MeasurementOutput)
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package com.android.systemui.util.animation
import android.view.View
/**
* The output of a view measurement
*/
data class MeasurementOutput(
var measuredWidth: Int,
var measuredHeight: Int
)
/**
* The data object holding a basic view measurement input
*/
data class MeasurementInput(
var widthMeasureSpec: Int,
var heightMeasureSpec: Int
) {
val width: Int
get() {
return View.MeasureSpec.getSize(widthMeasureSpec)
}
val height: Int
get() {
return View.MeasureSpec.getSize(heightMeasureSpec)
}
}

View File

@@ -0,0 +1,294 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package com.android.systemui.util.animation
import android.content.Context
import android.graphics.Rect
import android.util.AttributeSet
import android.view.View
import android.view.ViewTreeObserver
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import com.android.systemui.statusbar.CrossFadeHelper
/**
* A view that handles displaying of children and transitions of them in an optimized way,
* minimizing the number of measure passes, while allowing for maximum flexibility
* and interruptibility.
*/
class TransitionLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val originalGoneChildrenSet: MutableSet<Int> = mutableSetOf()
private var measureAsConstraint: Boolean = false
private var currentState: TransitionViewState = TransitionViewState()
private var updateScheduled = false
/**
* The measured state of this view which is the one we will lay ourselves out with. This
* may differ from the currentState if there is an external animation or transition running.
* This state will not be used to measure the widgets, where the current state is preferred.
*/
var measureState: TransitionViewState = TransitionViewState()
private val preDrawApplicator = object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
updateScheduled = false
viewTreeObserver.removeOnPreDrawListener(this)
applyCurrentState()
return true
}
}
override fun onFinishInflate() {
super.onFinishInflate()
val childCount = childCount
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.id == View.NO_ID) {
child.id = i
}
if (child.visibility == GONE) {
originalGoneChildrenSet.add(child.id)
}
}
}
/**
* Apply the current state to the view and its widgets
*/
private fun applyCurrentState() {
val childCount = childCount
for (i in 0 until childCount) {
val child = getChildAt(i)
val widgetState = currentState.widgetStates.get(child.id) ?: continue
if (child.measuredWidth != widgetState.measureWidth ||
child.measuredHeight != widgetState.measureHeight) {
val measureWidthSpec = MeasureSpec.makeMeasureSpec(widgetState.measureWidth,
MeasureSpec.EXACTLY)
val measureHeightSpec = MeasureSpec.makeMeasureSpec(widgetState.measureHeight,
MeasureSpec.EXACTLY)
child.measure(measureWidthSpec, measureHeightSpec)
child.layout(0, 0, child.measuredWidth, child.measuredHeight)
}
val left = widgetState.x.toInt()
val top = widgetState.y.toInt()
child.setLeftTopRightBottom(left, top, left + widgetState.width,
top + widgetState.height)
child.scaleX = widgetState.scale
child.scaleY = widgetState.scale
val clipBounds = child.clipBounds ?: Rect()
clipBounds.set(0, 0, widgetState.width, widgetState.height)
child.clipBounds = clipBounds
CrossFadeHelper.fadeIn(child, widgetState.alpha)
child.visibility = if (widgetState.gone || widgetState.alpha == 0.0f) {
View.INVISIBLE
} else {
View.VISIBLE
}
}
updateBounds()
}
private fun applyCurrentStateOnPredraw() {
if (!updateScheduled) {
updateScheduled = true
viewTreeObserver.addOnPreDrawListener(preDrawApplicator)
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
if (measureAsConstraint) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
} else {
for (i in 0 until childCount) {
val child = getChildAt(i)
val widgetState = currentState.widgetStates.get(child.id) ?: continue
val measureWidthSpec = MeasureSpec.makeMeasureSpec(widgetState.measureWidth,
MeasureSpec.EXACTLY)
val measureHeightSpec = MeasureSpec.makeMeasureSpec(widgetState.measureHeight,
MeasureSpec.EXACTLY)
child.measure(measureWidthSpec, measureHeightSpec)
}
setMeasuredDimension(measureState.width, measureState.height)
}
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
if (measureAsConstraint) {
super.onLayout(changed, left, top, right, bottom)
} else {
val childCount = childCount
for (i in 0 until childCount) {
val child = getChildAt(i)
child.layout(0, 0, child.measuredWidth, child.measuredHeight)
}
// Reapply the bounds to update the background
applyCurrentState()
}
}
private fun updateBounds() {
val layoutLeft = left
val layoutTop = top
setLeftTopRightBottom(layoutLeft, layoutTop, layoutLeft + currentState.width,
layoutTop + currentState.height)
}
/**
* Calculates a view state for a given ConstraintSet and measurement, saving all positions
* of all widgets.
*
* @param input the measurement input this should be done with
* @param constraintSet the constraint set to apply
* @param resusableState the result that we can reuse to minimize memory impact
*/
fun calculateViewState(
input: MeasurementInput,
constraintSet: ConstraintSet,
existing: TransitionViewState? = null
): TransitionViewState {
val result = existing ?: TransitionViewState()
// Reset gone children to the original state
applySetToFullLayout(constraintSet)
val previousHeight = measuredHeight
val previousWidth = measuredWidth
// Let's measure outselves as a ConstraintLayout
measureAsConstraint = true
measure(input.widthMeasureSpec, input.heightMeasureSpec)
val layoutLeft = left
val layoutTop = top
layout(layoutLeft, layoutTop, layoutLeft + measuredWidth, layoutTop + measuredHeight)
measureAsConstraint = false
result.initFromLayout(this)
ensureViewsNotGone()
// Let's reset our layout to have the right size again
setMeasuredDimension(previousWidth, previousHeight)
applyCurrentStateOnPredraw()
return result
}
private fun applySetToFullLayout(constraintSet: ConstraintSet) {
// Let's reset our views to the initial gone state of the layout, since the constraintset
// might only be a subset of the views. Otherwise the gone state would be calculated
// wrongly later if we made this invisible in the layout (during apply we make sure they
// are invisible instead
val childCount = childCount
for (i in 0 until childCount) {
val child = getChildAt(i)
if (originalGoneChildrenSet.contains(child.id)) {
child.visibility = View.GONE
}
}
// Let's now apply the constraintSet to get the full state
constraintSet.applyTo(this)
}
/**
* Ensures that our views are never gone but invisible instead, this allows us to animate them
* without remeasuring.
*/
private fun ensureViewsNotGone() {
val childCount = childCount
for (i in 0 until childCount) {
val child = getChildAt(i)
val widgetState = currentState.widgetStates.get(child.id)
child.visibility = if (widgetState?.gone != false) View.INVISIBLE else View.VISIBLE
}
}
/**
* Set the state that should be applied to this View
*
*/
fun setState(state: TransitionViewState) {
currentState = state
applyCurrentState()
}
}
class TransitionViewState {
var widgetStates: MutableMap<Int, WidgetState> = mutableMapOf()
var width: Int = 0
var height: Int = 0
fun copy(reusedState: TransitionViewState? = null): TransitionViewState {
// we need a deep copy of this, so we can't use a data class
val copy = reusedState ?: TransitionViewState()
copy.width = width
copy.height = height
for (entry in widgetStates) {
copy.widgetStates[entry.key] = entry.value.copy()
}
return copy
}
fun initFromLayout(transitionLayout: TransitionLayout) {
val childCount = transitionLayout.childCount
for (i in 0 until childCount) {
val child = transitionLayout.getChildAt(i)
val widgetState = widgetStates.getOrPut(child.id, {
WidgetState(0.0f, 0.0f, 0, 0, 0, 0, 0.0f)
})
widgetState.initFromLayout(child)
}
width = transitionLayout.measuredWidth
height = transitionLayout.measuredHeight
}
}
data class WidgetState(
var x: Float = 0.0f,
var y: Float = 0.0f,
var width: Int = 0,
var height: Int = 0,
var measureWidth: Int = 0,
var measureHeight: Int = 0,
var alpha: Float = 1.0f,
var scale: Float = 1.0f,
var gone: Boolean = false
) {
fun initFromLayout(view: View) {
gone = view.visibility == View.GONE
if (gone) {
val layoutParams = view.layoutParams as ConstraintLayout.LayoutParams
x = layoutParams.constraintWidget.left.toFloat()
y = layoutParams.constraintWidget.top.toFloat()
width = layoutParams.constraintWidget.width
height = layoutParams.constraintWidget.height
measureHeight = height
measureWidth = width
alpha = 0.0f
scale = 0.0f
} else {
x = view.left.toFloat()
y = view.top.toFloat()
width = view.width
height = view.height
measureWidth = width
measureHeight = height
gone = view.visibility == View.GONE
alpha = view.alpha
// No scale by default. Only during transitions!
scale = 1.0f
}
}
}

View File

@@ -0,0 +1,233 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package com.android.systemui.util.animation
import android.animation.ValueAnimator
import android.util.MathUtils
import com.android.systemui.Interpolators
/**
* The fraction after which we start fading in when going from a gone widget to a visible one
*/
private const val GONE_FADE_FRACTION = 0.8f
/**
* The amont we're scaling appearing views
*/
private const val GONE_SCALE_AMOUNT = 0.8f
/**
* A controller for a [TransitionLayout] which handles state transitions and keeps the transition
* layout up to date with the desired state.
*/
open class TransitionLayoutController {
/**
* The layout that this controller controls
*/
private var transitionLayout: TransitionLayout? = null
private var currentState = TransitionViewState()
private var animationStartState: TransitionViewState? = null
private var state = TransitionViewState()
private var animator: ValueAnimator = ValueAnimator.ofFloat(0.0f, 1.0f)
init {
animator.apply {
addUpdateListener {
updateStateFromAnimation()
}
interpolator = Interpolators.FAST_OUT_SLOW_IN
}
}
private fun updateStateFromAnimation() {
if (animationStartState == null || !animator.isRunning) {
return
}
val view = transitionLayout ?: return
getInterpolatedState(
startState = animationStartState!!,
endState = state,
progress = animator.animatedFraction,
resultState = currentState)
view.setState(currentState)
}
/**
* Get an interpolated state between two viewstates. This interpolates all positions for all
* widgets as well as it's bounds based on the given input.
*/
fun getInterpolatedState(
startState: TransitionViewState,
endState: TransitionViewState,
progress: Float,
resultState: TransitionViewState
) {
val view = transitionLayout ?: return
val childCount = view.childCount
for (i in 0 until childCount) {
val id = view.getChildAt(i).id
val resultWidgetState = resultState.widgetStates[id] ?: WidgetState()
val widgetStart = startState.widgetStates[id] ?: continue
val widgetEnd = endState.widgetStates[id] ?: continue
var alphaProgress = progress
var widthProgress = progress
val resultMeasureWidth: Int
val resultMeasureHeight: Int
val newScale: Float
val resultX: Float
val resultY: Float
if (widgetStart.gone != widgetEnd.gone) {
// A view is appearing or disappearing. Let's not just interpolate between them as
// this looks quite ugly
val nowGone: Boolean
if (widgetStart.gone) {
// Only fade it in at the very end
alphaProgress = MathUtils.map(GONE_FADE_FRACTION, 1.0f, 0.0f, 1.0f, progress)
nowGone = progress < GONE_FADE_FRACTION
// Scale it just a little, not all the way
val endScale = widgetEnd.scale
newScale = MathUtils.lerp(GONE_SCALE_AMOUNT * endScale, endScale, progress)
// don't clip
widthProgress = 1.0f
// Let's directly measure it with the end state
resultMeasureWidth = widgetEnd.measureWidth
resultMeasureHeight = widgetEnd.measureHeight
// Let's make sure we're centering the view in the gone view instead of having
// the left at 0
resultX = MathUtils.lerp(widgetStart.x - resultMeasureWidth / 2.0f,
widgetEnd.x,
progress)
resultY = MathUtils.lerp(widgetStart.y - resultMeasureHeight / 2.0f,
widgetEnd.y,
progress)
} else {
// Fadeout in the very beginning
alphaProgress = MathUtils.map(0.0f, 1.0f - GONE_FADE_FRACTION, 0.0f, 1.0f,
progress)
nowGone = progress > 1.0f - GONE_FADE_FRACTION
// Scale it just a little, not all the way
val startScale = widgetStart.scale
newScale = MathUtils.lerp(startScale, startScale * GONE_SCALE_AMOUNT, progress)
// Don't clip
widthProgress = 0.0f
// Let's directly measure it with the start state
resultMeasureWidth = widgetStart.measureWidth
resultMeasureHeight = widgetStart.measureHeight
// Let's make sure we're centering the view in the gone view instead of having
// the left at 0
resultX = MathUtils.lerp(widgetStart.x,
widgetEnd.x - resultMeasureWidth / 2.0f,
progress)
resultY = MathUtils.lerp(widgetStart.y,
widgetEnd.y - resultMeasureHeight / 2.0f,
progress)
}
resultWidgetState.gone = nowGone
} else {
resultWidgetState.gone = widgetStart.gone
// Let's directly measure it with the end state
resultMeasureWidth = widgetEnd.measureWidth
resultMeasureHeight = widgetEnd.measureHeight
newScale = MathUtils.lerp(widgetStart.scale, widgetEnd.scale, progress)
resultX = MathUtils.lerp(widgetStart.x, widgetEnd.x, progress)
resultY = MathUtils.lerp(widgetStart.y, widgetEnd.y, progress)
}
resultWidgetState.apply {
x = resultX
y = resultY
alpha = MathUtils.lerp(widgetStart.alpha, widgetEnd.alpha, alphaProgress)
width = MathUtils.lerp(widgetStart.width.toFloat(), widgetEnd.width.toFloat(),
widthProgress).toInt()
height = MathUtils.lerp(widgetStart.height.toFloat(), widgetEnd.height.toFloat(),
widthProgress).toInt()
scale = newScale
// Let's directly measure it with the end state
measureWidth = resultMeasureWidth
measureHeight = resultMeasureHeight
}
resultState.widgetStates[id] = resultWidgetState
}
resultState.apply {
width = MathUtils.lerp(startState.width.toFloat(), endState.width.toFloat(),
progress).toInt()
height = MathUtils.lerp(startState.height.toFloat(), endState.height.toFloat(),
progress).toInt()
}
}
fun attach(transitionLayout: TransitionLayout) {
this.transitionLayout = transitionLayout
}
/**
* Set a new state to be applied to the dynamic view.
*
* @param state the state to be applied
* @param animate should this change be animated. If [false] the we will either apply the
* state immediately if no animation is running, and if one is running, we will update the end
* value to match the new state.
* @param applyImmediately should this change be applied immediately, canceling all running
* animations
*/
fun setState(
state: TransitionViewState,
applyImmediately: Boolean,
animate: Boolean,
duration: Long = 0,
delay: Long = 0
) {
val animated = animate && currentState.width != 0
this.state = state.copy()
if (applyImmediately || transitionLayout == null) {
animator.cancel()
transitionLayout?.setState(this.state)
currentState = state.copy(reusedState = currentState)
} else if (animated) {
animationStartState = currentState.copy()
animator.duration = duration
animator.startDelay = delay
animator.start()
} else if (!animator.isRunning) {
transitionLayout?.setState(this.state)
currentState = state.copy(reusedState = currentState)
}
// otherwise the desired state was updated and the animation will go to the new target
}
/**
* Set a new state that will be used to measure the view itself and is useful during
* transitions, where the state set via [setState] may differ from how the view
* should be measured.
*/
fun setMeasureState(
state: TransitionViewState
) {
transitionLayout?.measureState = state
}
}

View File

@@ -19,7 +19,9 @@ package com.android.systemui.util.animation
import android.annotation.SuppressLint
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import com.android.systemui.R
/**
* A special view that is designed to host a single "unique object". The unique object is
@@ -34,8 +36,7 @@ import android.widget.FrameLayout
class UniqueObjectHostView(
context: Context
) : FrameLayout(context) {
lateinit var measurementCache : GuaranteedMeasurementCache
var onMeasureListener: ((MeasurementInput) -> Unit)? = null
lateinit var measurementManager: MeasurementManager
@SuppressLint("DrawAllocation")
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
@@ -45,64 +46,63 @@ class UniqueObjectHostView(
val widthSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.getMode(widthMeasureSpec))
val height = MeasureSpec.getSize(heightMeasureSpec) - paddingVertical
val heightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.getMode(heightMeasureSpec))
val measurementInput = MeasurementInputData(widthSpec, heightSpec)
onMeasureListener?.apply {
invoke(measurementInput)
}
val measurementInput = MeasurementInput(widthSpec, heightSpec)
// Let's make sure the measurementManager knows about our size, to ensure that we have
// a value available. This might perform a measure internally if we don't have a cached
// size.
val (cachedWidth, cachedHeight) = measurementManager.onMeasure(measurementInput)
if (!isCurrentHost()) {
// We're not currently the host, let's get the dimension from our cache (this might
// perform a measuring if the cache doesn't have it yet)
// We're not currently the host, let's use the dimension from our cache
// The goal here is that the view will always have a consistent measuring, regardless
// if it's attached or not.
// The behavior is therefore very similar to the view being persistently attached to
// this host, which can prevent flickers. It also makes sure that we always know
// the size of the view during transitions even if it has never been attached here
// before.
val (cachedWidth, cachedHeight) = measurementCache.obtainMeasurement(measurementInput)
setMeasuredDimension(cachedWidth + paddingHorizontal, cachedHeight + paddingVertical)
} else {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// Let's update our cache
val child = getChildAt(0)!!
val output = MeasurementOutput(child.measuredWidth, child.measuredHeight)
measurementCache.putMeasurement(measurementInput, output)
getChildAt(0)?.requiresRemeasuring = false
}
}
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
if (child?.measuredWidth == 0 || measuredWidth == 0 || child?.requiresRemeasuring == true) {
super.addView(child, index, params)
return
}
// Suppress layouts when adding a view. The view should already be laid out with the
// right size when being attached to this view
invalidate()
addViewInLayout(child, index, params, true /* preventRequestLayout */)
val left = paddingLeft
val top = paddingTop
val paddingHorizontal = paddingStart + paddingEnd
val paddingVertical = paddingTop + paddingBottom
child!!.layout(left,
top,
left + measuredWidth - paddingHorizontal,
top + measuredHeight - paddingVertical)
}
private fun isCurrentHost() = childCount != 0
}
/**
* A basic view measurement input
*/
interface MeasurementInput {
fun sameAs(input: MeasurementInput?): Boolean {
return equals(input)
interface MeasurementManager {
fun onMeasure(input: MeasurementInput): MeasurementOutput
}
val width : Int
get() {
return View.MeasureSpec.getSize(widthMeasureSpec)
}
val height : Int
get() {
return View.MeasureSpec.getSize(heightMeasureSpec)
}
var widthMeasureSpec: Int
var heightMeasureSpec: Int
}
/**
* The output of a view measurement
* Does this view require remeasuring currently outside of the regular measure flow?
*/
data class MeasurementOutput(
val measuredWidth: Int,
val measuredHeight: Int
)
/**
* The data object holding a basic view measurement input
*/
data class MeasurementInputData(
override var widthMeasureSpec: Int,
override var heightMeasureSpec: Int
) : MeasurementInput
var View.requiresRemeasuring: Boolean
get() {
val required = getTag(R.id.requires_remeasuring)
return required?.equals(true) ?: false
}
set(value) {
setTag(R.id.requires_remeasuring, value)
}

View File

@@ -31,30 +31,24 @@ import android.widget.ImageButton
import android.widget.ImageView
import android.widget.SeekBar
import android.widget.TextView
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.constraintlayout.motion.widget.MotionScene
import androidx.constraintlayout.widget.ConstraintSet
import androidx.test.filters.SmallTest
import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.plugins.ActivityStarter
import com.android.systemui.util.animation.TransitionLayout
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.time.FakeSystemClock
import com.google.common.truth.Truth.assertThat
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.Mock
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when` as whenever
import java.util.ArrayList
private const val KEY = "TEST_KEY"
private const val APP = "APP"
private const val BG_COLOR = Color.RED
@@ -78,8 +72,8 @@ public class MediaControlPanelTest : SysuiTestCase() {
@Mock private lateinit var activityStarter: ActivityStarter
@Mock private lateinit var holder: PlayerViewHolder
@Mock private lateinit var motion: MotionLayout
private lateinit var background: TextView
@Mock private lateinit var view: TransitionLayout
@Mock private lateinit var mediaHostStatesManager: MediaHostStatesManager
private lateinit var appIcon: ImageView
private lateinit var appName: TextView
private lateinit var albumView: ImageView
@@ -107,21 +101,15 @@ public class MediaControlPanelTest : SysuiTestCase() {
bgExecutor = FakeExecutor(FakeSystemClock())
activityStarter = mock(ActivityStarter::class.java)
mediaHostStatesManager = mock(MediaHostStatesManager::class.java)
player = MediaControlPanel(context, fgExecutor, bgExecutor, activityStarter)
player = MediaControlPanel(context, fgExecutor, bgExecutor, activityStarter,
mediaHostStatesManager)
// Mock out a view holder for the player to attach to.
holder = mock(PlayerViewHolder::class.java)
motion = mock(MotionLayout::class.java)
val trans: ArrayList<MotionScene.Transition> = ArrayList()
trans.add(mock(MotionScene.Transition::class.java))
whenever(motion.definedTransitions).thenReturn(trans)
val constraintSet = mock(ConstraintSet::class.java)
whenever(motion.getConstraintSet(R.id.expanded)).thenReturn(constraintSet)
whenever(motion.getConstraintSet(R.id.collapsed)).thenReturn(constraintSet)
whenever(holder.player).thenReturn(motion)
background = TextView(context)
whenever(holder.background).thenReturn(background)
view = mock(TransitionLayout::class.java)
whenever(holder.player).thenReturn(view)
appIcon = ImageView(context)
whenever(holder.appIcon).thenReturn(appIcon)
appName = TextView(context)
@@ -205,7 +193,9 @@ public class MediaControlPanelTest : SysuiTestCase() {
val state = MediaData(true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(),
emptyList(), PACKAGE, session.getSessionToken(), null, device)
player.bind(state)
assertThat(background.getBackgroundTintList()).isEqualTo(ColorStateList.valueOf(BG_COLOR))
val list = ArgumentCaptor.forClass(ColorStateList::class.java)
verify(view).setBackgroundTintList(list.capture())
assertThat(list.value).isEqualTo(ColorStateList.valueOf(BG_COLOR))
}
@Test

View File

@@ -0,0 +1,145 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.media
import android.graphics.Rect
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.view.ViewGroup
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.controls.controller.ControlsControllerImplTest.Companion.eq
import com.android.systemui.keyguard.WakefulnessLifecycle
import com.android.systemui.statusbar.NotificationLockscreenUserManager
import com.android.systemui.statusbar.StatusBarState
import com.android.systemui.statusbar.SysuiStatusBarStateController
import com.android.systemui.statusbar.phone.KeyguardBypassController
import com.android.systemui.statusbar.policy.KeyguardStateController
import com.android.systemui.util.animation.UniqueObjectHostView
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers
import org.mockito.Captor
import org.mockito.Mock
import org.mockito.Mockito.`when`
import org.mockito.Mockito.any
import org.mockito.Mockito.anyBoolean
import org.mockito.Mockito.anyLong
import org.mockito.Mockito.clearInvocations
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnit
@SmallTest
@RunWith(AndroidTestingRunner::class)
@TestableLooper.RunWithLooper
class MediaHierarchyManagerTest : SysuiTestCase() {
@Mock
private lateinit var lockHost: MediaHost
@Mock
private lateinit var qsHost: MediaHost
@Mock
private lateinit var qqsHost: MediaHost
@Mock
private lateinit var bypassController: KeyguardBypassController
@Mock
private lateinit var mediaFrame: ViewGroup
@Mock
private lateinit var keyguardStateController: KeyguardStateController
@Mock
private lateinit var statusBarStateController: SysuiStatusBarStateController
@Mock
private lateinit var notificationLockscreenUserManager: NotificationLockscreenUserManager
@Mock
private lateinit var mediaViewManager: MediaViewManager
@Mock
private lateinit var wakefulnessLifecycle: WakefulnessLifecycle
@Captor
private lateinit var wakefullnessObserver: ArgumentCaptor<(WakefulnessLifecycle.Observer)>
@JvmField
@Rule
val mockito = MockitoJUnit.rule()
private lateinit var mediaHiearchyManager: MediaHierarchyManager
@Before
fun setup() {
`when`(mediaViewManager.mediaFrame).thenReturn(mediaFrame)
mediaHiearchyManager = MediaHierarchyManager(
context,
statusBarStateController,
keyguardStateController,
bypassController,
mediaViewManager,
notificationLockscreenUserManager,
wakefulnessLifecycle)
verify(wakefulnessLifecycle).addObserver(wakefullnessObserver.capture())
setupHost(lockHost, MediaHierarchyManager.LOCATION_LOCKSCREEN)
setupHost(qsHost, MediaHierarchyManager.LOCATION_QS)
setupHost(qqsHost, MediaHierarchyManager.LOCATION_QQS)
`when`(statusBarStateController.state).thenReturn(StatusBarState.SHADE)
// We'll use the viewmanager to verify a few calls below, let's reset this.
clearInvocations(mediaViewManager)
}
private fun setupHost(host: MediaHost, location: Int) {
`when`(host.location).thenReturn(location)
`when`(host.currentBounds).thenReturn(Rect())
`when`(host.hostView).thenReturn(UniqueObjectHostView(context))
mediaHiearchyManager.register(host)
}
@Test
fun testHostViewSetOnRegister() {
val host = mediaHiearchyManager.register(lockHost)
verify(lockHost).hostView = eq(host)
}
@Test
fun testBlockedWhenScreenTurningOff() {
// Let's set it onto QS:
mediaHiearchyManager.qsExpansion = 1.0f
verify(mediaViewManager).onDesiredLocationChanged(ArgumentMatchers.anyInt(),
any(MediaHostState::class.java), anyBoolean(), anyLong(), anyLong())
val observer = wakefullnessObserver.value
assertNotNull("lifecycle observer wasn't registered", observer)
observer.onStartedGoingToSleep()
clearInvocations(mediaViewManager)
mediaHiearchyManager.qsExpansion = 0.0f
verify(mediaViewManager, times(0)).onDesiredLocationChanged(ArgumentMatchers.anyInt(),
any(MediaHostState::class.java), anyBoolean(), anyLong(), anyLong())
}
@Test
fun testAllowedWhenNotTurningOff() {
// Let's set it onto QS:
mediaHiearchyManager.qsExpansion = 1.0f
verify(mediaViewManager).onDesiredLocationChanged(ArgumentMatchers.anyInt(),
any(MediaHostState::class.java), anyBoolean(), anyLong(), anyLong())
val observer = wakefullnessObserver.value
assertNotNull("lifecycle observer wasn't registered", observer)
clearInvocations(mediaViewManager)
mediaHiearchyManager.qsExpansion = 0.0f
verify(mediaViewManager).onDesiredLocationChanged(ArgumentMatchers.anyInt(),
any(MediaHostState::class.java), anyBoolean(), anyLong(), anyLong())
}
}

View File

@@ -57,6 +57,6 @@ class PlayerViewHolderTest : SysuiTestCase() {
@Test
fun backgroundIsIlluminationDrawable() {
val holder = PlayerViewHolder.create(inflater, parent)
assertThat(holder.background.background as IlluminationDrawable).isNotNull()
assertThat(holder.player.background as IlluminationDrawable).isNotNull()
}
}