diff options
33 files changed, 1975 insertions, 1162 deletions
diff --git a/packages/SystemUI/res/layout/qs_media_panel.xml b/packages/SystemUI/res/layout/media_view.xml index bf062421ddb4..1a1fddbcfd03 100644 --- a/packages/SystemUI/res/layout/qs_media_panel.xml +++ b/packages/SystemUI/res/layout/media_view.xml @@ -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> diff --git a/packages/SystemUI/res/values/ids.xml b/packages/SystemUI/res/values/ids.xml index 09918e764140..d47ad7fcdfbd 100644 --- a/packages/SystemUI/res/values/ids.xml +++ b/packages/SystemUI/res/values/ids.xml @@ -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"/> diff --git a/packages/SystemUI/res/xml/media_collapsed.xml b/packages/SystemUI/res/xml/media_collapsed.xml new file mode 100644 index 000000000000..57e6f3635785 --- /dev/null +++ b/packages/SystemUI/res/xml/media_collapsed.xml @@ -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> diff --git a/packages/SystemUI/res/xml/media_expanded.xml b/packages/SystemUI/res/xml/media_expanded.xml new file mode 100644 index 000000000000..78973f3207d1 --- /dev/null +++ b/packages/SystemUI/res/xml/media_expanded.xml @@ -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> diff --git a/packages/SystemUI/res/xml/media_scene.xml b/packages/SystemUI/res/xml/media_scene.xml deleted file mode 100644 index f61b2b096d3c..000000000000 --- a/packages/SystemUI/res/xml/media_scene.xml +++ /dev/null @@ -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> diff --git a/packages/SystemUI/src/com/android/systemui/media/GoneChildrenHideHelper.kt b/packages/SystemUI/src/com/android/systemui/media/GoneChildrenHideHelper.kt deleted file mode 100644 index 2fe0d9f4711f..000000000000 --- a/packages/SystemUI/src/com/android/systemui/media/GoneChildrenHideHelper.kt +++ /dev/null @@ -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) - } - } - } -}
\ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/media/KeyguardMediaController.kt b/packages/SystemUI/src/com/android/systemui/media/KeyguardMediaController.kt index 85e1c6b77be4..5f43e43c03c6 100644 --- a/packages/SystemUI/src/com/android/systemui/media/KeyguardMediaController.kt +++ b/packages/SystemUI/src/com/android/systemui/media/KeyguardMediaController.kt @@ -45,6 +45,7 @@ class KeyguardMediaController @Inject constructor( } }) } + private var view: MediaHeaderView? = null /** diff --git a/packages/SystemUI/src/com/android/systemui/media/LayoutAnimationHelper.kt b/packages/SystemUI/src/com/android/systemui/media/LayoutAnimationHelper.kt deleted file mode 100644 index a366725a4398..000000000000 --- a/packages/SystemUI/src/com/android/systemui/media/LayoutAnimationHelper.kt +++ /dev/null @@ -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 - } - }) - } - } -}
\ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java index c7b93262b181..8e1e1b27cadf 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java +++ b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java @@ -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() { @@ -165,6 +164,15 @@ public class MediaControlPanel { } /** + * 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. * * Should be set to true when the QS panel is open. Otherwise, false. This is a signal to avoid @@ -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 @@ -412,30 +417,6 @@ public class MediaControlPanel { } /** - * 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); - } } diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt index cce1d3efd6e5..775a1649702a 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaHierarchyManager.kt @@ -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
\ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaHost.kt b/packages/SystemUI/src/com/android/systemui/media/MediaHost.kt index 240e44cb8db4..e904e935b0e0 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaHost.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaHost.kt @@ -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 + class MediaHostStateHolder @Inject constructor() : 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 - override val boundsOnScreen: Rect = Rect() + 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 - override fun copy(): MediaState { - val mediaHostState = MediaHostState() + /** + * 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 } - return result + if (!Objects.equals(measurementInput, other.measurementInput)) { + return false + } + if (expansion != other.expansion) { + return false + } + if (showsOnlyActiveMedia != other.showsOnlyActiveMedia) { + return false + } + return true } - override fun getMeasuringInput(input: MeasurementInput): MediaMeasurementInput { - measurementInput = MediaMeasurementInput(input, expansion) - return measurementInput as MediaMeasurementInput + override fun hashCode(): Int { + var result = measurementInput?.hashCode() ?: 0 + result = 31 * result + expansion.hashCode() + result = 31 * result + showsOnlyActiveMedia.hashCode() + return result } } } -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 + + /** + * Is this host only showing active media or is it showing all of them including resumption? + */ 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 - } + + /** + * Get a copy of this view state, deepcopying all appropriate members + */ + fun copy(): MediaHostState }
\ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaHostStatesManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaHostStatesManager.kt new file mode 100644 index 000000000000..f90af2a01de0 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/MediaHostStatesManager.kt @@ -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) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaMeasurementManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaMeasurementManager.kt deleted file mode 100644 index 4bbf5eb9f0dc..000000000000 --- a/packages/SystemUI/src/com/android/systemui/media/MediaMeasurementManager.kt +++ /dev/null @@ -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) - } -} - diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaViewController.kt b/packages/SystemUI/src/com/android/systemui/media/MediaViewController.kt new file mode 100644 index 000000000000..e82bb402407e --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/MediaViewController.kt @@ -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 + } +} diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt index 16302d1632b5..8ab30c75c7eb 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt @@ -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 + this.desiredLocation = desiredLocation + this.desiredHostState = it + currentlyExpanded = it.expansion > 0 + for (mediaPlayer in mediaPlayers.values) { + if (animate) { + mediaPlayer.mediaViewController.animatePendingStateChange( + duration = duration, + delay = startDelay) } + mediaPlayer.mediaViewController.onLocationPreChange(desiredLocation) } + updateCarouselSize() } } - fun setPlayerWidth(width: Int) { - if (width != playerWidth) { - playerWidth = width - playerWidthPlusPadding = playerWidth + context.resources.getDimensionPixelSize( + /** + * Update the size of the carousel, remeasuring it if necessary. + */ + 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) - for (mediaPlayer in mediaPlayers.values) { - mediaPlayer.setPlayerWidth(width) - } // 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) + newScroll += playerWidthPlusPadding - + (scrollIntoCurrentMedia - playerWidthPlusPadding) } else { newScroll += scrollIntoCurrentMedia } mediaCarousel.scrollX = newScroll - } - } - - /** - * 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 - */ - 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) - } + // 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) } } } diff --git a/packages/SystemUI/src/com/android/systemui/media/PlayerViewHolder.kt b/packages/SystemUI/src/com/android/systemui/media/PlayerViewHolder.kt index 764dbe6d428f..60c576bd6c34 100644 --- a/packages/SystemUI/src/com/android/systemui/media/PlayerViewHolder.kt +++ b/packages/SystemUI/src/com/android/systemui/media/PlayerViewHolder.kt @@ -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) } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSFragment.java b/packages/SystemUI/src/com/android/systemui/qs/QSFragment.java index b877e8745769..8f9e9e2eacd5 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSFragment.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSFragment.java @@ -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 { diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java b/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java index cdde06b0b143..4f0b56e705de 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java @@ -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( diff --git a/packages/SystemUI/src/com/android/systemui/qs/QuickQSPanel.java b/packages/SystemUI/src/com/android/systemui/qs/QuickQSPanel.java index 2f06c4b1fed0..75507beba7ae 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QuickQSPanel.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QuickQSPanel.java @@ -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(); } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/CrossFadeHelper.java b/packages/SystemUI/src/com/android/systemui/statusbar/CrossFadeHelper.java index b57b22fed853..8e6398f3630b 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/CrossFadeHelper.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/CrossFadeHelper.java @@ -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 */); } /** diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/CustomInterpolatorTransformation.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/CustomInterpolatorTransformation.java index dea1a07c0268..cb7da4fb9b56 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/CustomInterpolatorTransformation.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/CustomInterpolatorTransformation.java @@ -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; diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/TransformState.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/TransformState.java index 27109d2acfa2..9a8cff0f8dc1 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/TransformState.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/TransformState.java @@ -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) { diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridNotificationView.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridNotificationView.java index 207144931c3b..bc2adac31d07 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridNotificationView.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridNotificationView.java @@ -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(); diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapper.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapper.java index 2d99ab1e999f..14aab9d62dfd 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapper.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapper.java @@ -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); diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationViewWrapper.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationViewWrapper.java index fa7f282be74a..02e537d2879f 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationViewWrapper.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationViewWrapper.java @@ -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 diff --git a/packages/SystemUI/src/com/android/systemui/util/animation/MeasurementCache.kt b/packages/SystemUI/src/com/android/systemui/util/animation/MeasurementCache.kt deleted file mode 100644 index 2be698b4e796..000000000000 --- a/packages/SystemUI/src/com/android/systemui/util/animation/MeasurementCache.kt +++ /dev/null @@ -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) -} - diff --git a/packages/SystemUI/src/com/android/systemui/util/animation/MeasurementInput.kt b/packages/SystemUI/src/com/android/systemui/util/animation/MeasurementInput.kt new file mode 100644 index 000000000000..c7edd51e220a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/util/animation/MeasurementInput.kt @@ -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) + } +}
\ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/util/animation/TransitionLayout.kt b/packages/SystemUI/src/com/android/systemui/util/animation/TransitionLayout.kt new file mode 100644 index 000000000000..701ff5ecf8a1 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/util/animation/TransitionLayout.kt @@ -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 + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/util/animation/TransitionLayoutController.kt b/packages/SystemUI/src/com/android/systemui/util/animation/TransitionLayoutController.kt new file mode 100644 index 000000000000..9ee141053861 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/util/animation/TransitionLayoutController.kt @@ -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 + } +} diff --git a/packages/SystemUI/src/com/android/systemui/util/animation/UniqueObjectHostView.kt b/packages/SystemUI/src/com/android/systemui/util/animation/UniqueObjectHostView.kt index bf94c5d36ff7..5b6444d8feaf 100644 --- a/packages/SystemUI/src/com/android/systemui/util/animation/UniqueObjectHostView.kt +++ b/packages/SystemUI/src/com/android/systemui/util/animation/UniqueObjectHostView.kt @@ -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) + } diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt index 4d30500bad45..b71a62c9d1a9 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt @@ -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 diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaHierarchyManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaHierarchyManagerTest.kt new file mode 100644 index 000000000000..c9e6f55ff59a --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaHierarchyManagerTest.kt @@ -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()) + } +}
\ No newline at end of file diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/PlayerViewHolderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/PlayerViewHolderTest.kt index 767852582dc3..d6849bf2aa39 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/PlayerViewHolderTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/PlayerViewHolderTest.kt @@ -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() } } |