diff --git a/packages/CarSystemUI/Android.mk b/packages/CarSystemUI/Android.mk new file mode 100644 index 0000000000000..0d40b7f22c5b1 --- /dev/null +++ b/packages/CarSystemUI/Android.mk @@ -0,0 +1,86 @@ +# LOCAL_PATH is not the current directory of this file. +# If you need the local path, use CAR_SYSUI_PATH. +# This tweak ensures that the resource overlay that is device-specific still works +# which requires that LOCAL_PATH match the original path (which must be frameworks/base/packages/SystemUI). +# +# For clarity, we also define SYSTEM_UI_AOSP_PATH to frameworks/base/packages/SystemUI and refer to that +SYSTEM_UI_AOSP_PATH := frameworks/base/packages/SystemUI +SYSTEM_UI_CAR_PATH := frameworks/base/packages/CarSystemUI +LOCAL_PATH := $(call my-dir) + +include $(CLEAR_VARS) + +LOCAL_USE_AAPT2 := true + +LOCAL_MODULE_TAGS := optional + +# The same as SYSTEM_UI_AOSP_PATH but based on the value of LOCAL_PATH which is +# frameworks/base/packages/CarSystemUI. +RELATIVE_SYSTEM_UI_AOSP_PATH := ../../../../$(SYSTEM_UI_AOSP_PATH) + + +LOCAL_SRC_FILES := \ + $(call all-java-files-under, src) \ + $(call all-Iaidl-files-under, src) \ + $(call all-java-files-under, $(RELATIVE_SYSTEM_UI_AOSP_PATH)/src) \ + $(call all-Iaidl-files-under, $(RELATIVE_SYSTEM_UI_AOSP_PATH)/src) + +LOCAL_STATIC_ANDROID_LIBRARIES := \ + SystemUIPluginLib \ + SystemUISharedLib \ + androidx.car_car \ + androidx.legacy_legacy-support-v4 \ + androidx.recyclerview_recyclerview \ + androidx.preference_preference \ + androidx.appcompat_appcompat \ + androidx.mediarouter_mediarouter \ + androidx.palette_palette \ + androidx.legacy_legacy-preference-v14 \ + androidx.leanback_leanback \ + androidx.slice_slice-core \ + androidx.slice_slice-view \ + androidx.slice_slice-builders \ + androidx.arch.core_core-runtime \ + androidx.lifecycle_lifecycle-extensions \ + +LOCAL_STATIC_JAVA_LIBRARIES := \ + SystemUI-tags \ + SystemUI-proto + +LOCAL_JAVA_LIBRARIES := telephony-common \ + android.car + +LOCAL_FULL_LIBS_MANIFEST_FILES := $(SYSTEM_UI_AOSP_PATH)/AndroidManifest.xml +LOCAL_MANIFEST_FILE := AndroidManifest.xml + +LOCAL_MODULE_OWNER := google +LOCAL_PACKAGE_NAME := CarSystemUI +LOCAL_PRIVATE_PLATFORM_APIS := true +LOCAL_CERTIFICATE := platform +LOCAL_PRIVILEGED_MODULE := true + +LOCAL_PROGUARD_FLAG_FILES := $(RELATIVE_SYSTEM_UI_AOSP_PATH)/proguard.flags \ + proguard.flags + +LOCAL_RESOURCE_DIR := \ + $(LOCAL_PATH)/res \ + $(SYSTEM_UI_AOSP_PATH)/res-keyguard \ + $(SYSTEM_UI_AOSP_PATH)/res + +ifneq ($(INCREMENTAL_BUILDS),) + LOCAL_PROGUARD_ENABLED := disabled + LOCAL_JACK_ENABLED := incremental +endif + +LOCAL_DX_FLAGS := --multi-dex +LOCAL_JACK_FLAGS := --multi-dex native + +include frameworks/base/packages/SettingsLib/common.mk + +LOCAL_OVERRIDES_PACKAGES := SystemUI + +LOCAL_AAPT_FLAGS := --extra-packages com.android.keyguard + +include $(BUILD_PACKAGE) + +include $(call all-makefiles-under, $(SYSTEM_UI_CAR_PATH)) diff --git a/packages/CarSystemUI/AndroidManifest.xml b/packages/CarSystemUI/AndroidManifest.xml new file mode 100644 index 0000000000000..4e8a3a3885a74 --- /dev/null +++ b/packages/CarSystemUI/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/packages/CarSystemUI/proguard.flags b/packages/CarSystemUI/proguard.flags new file mode 100644 index 0000000000000..ceb037cdb70f4 --- /dev/null +++ b/packages/CarSystemUI/proguard.flags @@ -0,0 +1 @@ +-keep class com.android.systemui.CarSystemUIFactory diff --git a/packages/CarSystemUI/res/values/attrs.xml b/packages/CarSystemUI/res/values/attrs.xml new file mode 100644 index 0000000000000..6178738652893 --- /dev/null +++ b/packages/CarSystemUI/res/values/attrs.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/CarSystemUI/res/values/config.xml b/packages/CarSystemUI/res/values/config.xml new file mode 100644 index 0000000000000..9dbe5b5906e53 --- /dev/null +++ b/packages/CarSystemUI/res/values/config.xml @@ -0,0 +1,83 @@ + + + + com.android.systemui.statusbar.car.CarStatusBar + com.android.systemui.CarSystemUIFactory + true + + + false + + + false + + + false + + + false + + + false + + + true + + + false + + + true + + + true + + + false + + + true + + + true + + + false + + + true + + + true + + + false + + + true + diff --git a/packages/CarSystemUI/src/com/android/systemui/CarSystemUIFactory.java b/packages/CarSystemUI/src/com/android/systemui/CarSystemUIFactory.java new file mode 100644 index 0000000000000..1d39f72b12dce --- /dev/null +++ b/packages/CarSystemUI/src/com/android/systemui/CarSystemUIFactory.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2018 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; + +import android.content.Context; +import android.util.ArrayMap; + +import com.android.internal.widget.LockPatternUtils; +import com.android.keyguard.ViewMediatorCallback; +import com.android.systemui.Dependency.DependencyProvider; +import com.android.systemui.car.CarNotificationEntryManager; +import com.android.systemui.statusbar.NotificationEntryManager; +import com.android.systemui.statusbar.car.CarFacetButtonController; +import com.android.systemui.statusbar.car.CarStatusBarKeyguardViewManager; +import com.android.systemui.statusbar.car.hvac.HvacController; +import com.android.systemui.statusbar.phone.StatusBarKeyguardViewManager; + +/** + * Class factory to provide car specific SystemUI components. + */ +public class CarSystemUIFactory extends SystemUIFactory { + + public StatusBarKeyguardViewManager createStatusBarKeyguardViewManager(Context context, + ViewMediatorCallback viewMediatorCallback, LockPatternUtils lockPatternUtils) { + return new CarStatusBarKeyguardViewManager(context, viewMediatorCallback, lockPatternUtils); + } + + @Override + public void injectDependencies(ArrayMap providers, + Context context) { + super.injectDependencies(providers, context); + providers.put(NotificationEntryManager.class, + () -> new CarNotificationEntryManager(context)); + providers.put(CarFacetButtonController.class, () -> new CarFacetButtonController(context)); + providers.put(HvacController.class, () -> new HvacController(context)); + } +} diff --git a/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/AnimatedTemperatureView.java b/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/AnimatedTemperatureView.java new file mode 100644 index 0000000000000..9b70d1de8c5c6 --- /dev/null +++ b/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/AnimatedTemperatureView.java @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2018 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.statusbar.hvac; + +import android.animation.ObjectAnimator; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.Rect; +import android.graphics.drawable.ColorDrawable; +import com.android.systemui.statusbar.car.hvac.TemperatureView; +import com.android.systemui.statusbar.car.hvac.HvacController; +import android.util.AttributeSet; +import android.util.Property; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextSwitcher; +import android.widget.TextView; + +import com.android.systemui.Dependency; +import com.android.systemui.R; + +/** + * Simple text display of HVAC properties, It is designed to show mTemperature and is configured in + * the XML. + * XML properties: + * hvacPropertyId - Example: CarHvacManager.ID_ZONED_TEMP_SETPOINT (16385) + * hvacAreaId - Example: VehicleSeat.SEAT_ROW_1_LEFT (1) + * hvacTempFormat - Example: "%.1f\u00B0" (1 decimal and the degree symbol) + * hvacOrientaion = Example: left + * + * Note: It registers itself with {@link HvacController} + */ +public class AnimatedTemperatureView extends FrameLayout implements TemperatureView { + + private static final float TEMPERATURE_EQUIVALENT_DELTA = .01f; + private static final Property COLOR_PROPERTY = + new Property(Integer.class, "color") { + + @Override + public Integer get(ColorDrawable object) { + return object.getColor(); + } + + @Override + public void set(ColorDrawable object, Integer value) { + object.setColor(value); + } + }; + + static boolean isHorizontal(int gravity) { + return Gravity.isHorizontal(gravity) + && (gravity & Gravity.HORIZONTAL_GRAVITY_MASK) != Gravity.CENTER_HORIZONTAL; + } + + @SuppressLint("RtlHardcoded") + static boolean isLeft(int gravity, int layoutDirection) { + return Gravity + .getAbsoluteGravity(gravity & Gravity.HORIZONTAL_GRAVITY_MASK, layoutDirection) + == Gravity.LEFT; + } + + static boolean isVertical(int gravity) { + return Gravity.isVertical(gravity) + && (gravity & Gravity.VERTICAL_GRAVITY_MASK) != Gravity.CENTER_VERTICAL; + } + + static boolean isTop(int gravity) { + return (gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.TOP; + } + + private final int mAreaId; + private final int mPropertyId; + private final int mPivotOffset; + private final int mGravity; + private final int mTextAppearanceRes; + private final int mMinEms; + private final Rect mPaddingRect; + private final float mMinValue; + private final float mMaxValue; + + private final ColorDrawable mBackgroundColor; + + private final TemperatureColorStore mColorStore = new TemperatureColorStore(); + private final TemperatureBackgroundAnimator mBackgroundAnimator; + private final TemperatureTextAnimator mTextAnimator; + + public AnimatedTemperatureView(Context context, AttributeSet attrs) { + super(context, attrs); + TypedArray typedArray = context.obtainStyledAttributes(attrs, + R.styleable.AnimatedTemperatureView); + mAreaId = typedArray.getInt(R.styleable.AnimatedTemperatureView_hvacAreaId, -1); + mPropertyId = typedArray.getInt(R.styleable.AnimatedTemperatureView_hvacPropertyId, -1); + mPivotOffset = + typedArray.getDimensionPixelOffset( + R.styleable.AnimatedTemperatureView_hvacPivotOffset, 0); + mGravity = typedArray.getInt(R.styleable.AnimatedTemperatureView_android_gravity, + Gravity.START); + mTextAppearanceRes = + typedArray.getResourceId(R.styleable.AnimatedTemperatureView_android_textAppearance, + 0); + mMinEms = typedArray.getInteger(R.styleable.AnimatedTemperatureView_android_minEms, 0); + mMinValue = typedArray.getFloat(R.styleable.AnimatedTemperatureView_hvacMinValue, + Float.NaN); + mMaxValue = typedArray.getFloat(R.styleable.AnimatedTemperatureView_hvacMaxValue, + Float.NaN); + + + mPaddingRect = + new Rect(getPaddingLeft(), getPaddingTop(), getPaddingRight(), getPaddingBottom()); + setPadding(0, 0, 0, 0); + + setClipChildren(false); + setClipToPadding(false); + + // init Views + TextSwitcher textSwitcher = new TextSwitcher(context); + textSwitcher.setFactory(this::generateTextView); + ImageView background = new ImageView(context); + mBackgroundColor = new ColorDrawable(Color.TRANSPARENT); + background.setImageDrawable(mBackgroundColor); + background.setVisibility(View.GONE); + + mBackgroundAnimator = new TemperatureBackgroundAnimator(this, background); + + + String format = typedArray.getString(R.styleable.AnimatedTemperatureView_hvacTempFormat); + format = (format == null) ? "%.1f\u00B0" : format; + CharSequence minText = typedArray.getString( + R.styleable.AnimatedTemperatureView_hvacMinText); + CharSequence maxText = typedArray.getString( + R.styleable.AnimatedTemperatureView_hvacMaxText); + mTextAnimator = new TemperatureTextAnimator(this, textSwitcher, format, mPivotOffset, + minText, maxText); + + addView(background, ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + addView(textSwitcher, ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + + typedArray.recycle(); + + // register with controller + HvacController hvacController = Dependency.get(HvacController.class); + hvacController.addHvacTextView(this); + } + + private TextView generateTextView() { + TextView textView = new TextView(getContext()); + textView.setTextAppearance(mTextAppearanceRes); + textView.setAllCaps(true); + textView.setMinEms(mMinEms); + textView.setGravity(mGravity); + textView.setPadding(mPaddingRect.left, mPaddingRect.top, mPaddingRect.right, + mPaddingRect.bottom); + textView.getViewTreeObserver() + .addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + if (isHorizontal(mGravity)) { + if (isLeft(mGravity, getLayoutDirection())) { + textView.setPivotX(-mPivotOffset); + } else { + textView.setPivotX(textView.getWidth() + mPivotOffset); + } + } + textView.getViewTreeObserver().removeOnPreDrawListener(this); + return false; + } + }); + textView.setLayoutParams(new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + + return textView; + } + + /** + * Formats the float for display + * + * @param temp - The current temp or NaN + */ + @Override + public void setTemp(float temp) { + mTextAnimator.setTemp(temp); + if (Float.isNaN(temp)) { + mBackgroundAnimator.hideCircle(); + return; + } + int color; + if (isMinValue(temp)) { + color = mColorStore.getMinColor(); + } else if (isMaxValue(temp)) { + color = mColorStore.getMaxColor(); + } else { + color = mColorStore.getColorForTemperature(temp); + } + if (mBackgroundAnimator.isOpen()) { + ObjectAnimator colorAnimator = + ObjectAnimator.ofInt(mBackgroundColor, COLOR_PROPERTY, color); + colorAnimator.setEvaluator((fraction, startValue, endValue) -> mColorStore + .lerpColor(fraction, (int) startValue, (int) endValue)); + colorAnimator.start(); + } else { + mBackgroundColor.setColor(color); + } + + mBackgroundAnimator.animateOpen(); + } + + boolean isMinValue(float temp) { + return !Float.isNaN(mMinValue) && isApproxEqual(temp, mMinValue); + } + + boolean isMaxValue(float temp) { + return !Float.isNaN(mMaxValue) && isApproxEqual(temp, mMaxValue); + } + + private boolean isApproxEqual(float left, float right) { + return Math.abs(left - right) <= TEMPERATURE_EQUIVALENT_DELTA; + } + + int getGravity() { + return mGravity; + } + + int getPivotOffset() { + return mPivotOffset; + } + + Rect getPaddingRect() { + return mPaddingRect; + } + + /** + * @return propertiyId Example: CarHvacManager.ID_ZONED_TEMP_SETPOINT (16385) + */ + @Override + public int getPropertyId() { + return mPropertyId; + } + + /** + * @return hvac AreaId - Example: VehicleSeat.SEAT_ROW_1_LEFT (1) + */ + @Override + public int getAreaId() { + return mAreaId; + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mBackgroundAnimator.stopAnimations(); + } + +} + diff --git a/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureBackgroundAnimator.java b/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureBackgroundAnimator.java new file mode 100644 index 0000000000000..0bc94b5003878 --- /dev/null +++ b/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureBackgroundAnimator.java @@ -0,0 +1,338 @@ +/* + * Copyright (c) 2018 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.statusbar.hvac; + +import static com.android.systemui.statusbar.hvac.AnimatedTemperatureView.isHorizontal; +import static com.android.systemui.statusbar.hvac.AnimatedTemperatureView.isLeft; +import static com.android.systemui.statusbar.hvac.AnimatedTemperatureView.isTop; +import static com.android.systemui.statusbar.hvac.AnimatedTemperatureView.isVertical; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.annotation.IntDef; +import android.graphics.Rect; +import android.view.View; +import android.view.ViewAnimationUtils; +import android.view.animation.AnticipateInterpolator; +import android.widget.ImageView; + +import java.util.ArrayList; +import java.util.List; + +/** + * Controls circular reveal animation of temperature background + */ +class TemperatureBackgroundAnimator { + + private static final AnticipateInterpolator ANTICIPATE_INTERPOLATOR = + new AnticipateInterpolator(); + private static final float MAX_OPACITY = .6f; + + private final View mAnimatedView; + + private int mPivotX; + private int mPivotY; + private int mGoneRadius; + private int mOvershootRadius; + private int mRestingRadius; + private int mBumpRadius; + + @CircleState + private int mCircleState; + + private Animator mCircularReveal; + private boolean mAnimationsReady; + + @IntDef({CircleState.GONE, CircleState.ENTERING, CircleState.OVERSHOT, CircleState.RESTING, + CircleState.RESTED, CircleState.BUMPING, CircleState.BUMPED, CircleState.EXITING}) + private @interface CircleState { + int GONE = 0; + int ENTERING = 1; + int OVERSHOT = 2; + int RESTING = 3; + int RESTED = 4; + int BUMPING = 5; + int BUMPED = 6; + int EXITING = 7; + } + + TemperatureBackgroundAnimator( + AnimatedTemperatureView parent, + ImageView animatedView) { + mAnimatedView = animatedView; + mAnimatedView.setAlpha(0); + + parent.addOnLayoutChangeListener( + (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> + setupAnimations(parent.getGravity(), parent.getPivotOffset(), + parent.getPaddingRect(), parent.getWidth(), parent.getHeight())); + } + + private void setupAnimations(int gravity, int pivotOffset, Rect paddingRect, + int width, int height) { + int padding; + if (isHorizontal(gravity)) { + mGoneRadius = pivotOffset; + if (isLeft(gravity, mAnimatedView.getLayoutDirection())) { + mPivotX = -pivotOffset; + padding = paddingRect.right; + } else { + mPivotX = width + pivotOffset; + padding = paddingRect.left; + } + mPivotY = height / 2; + mOvershootRadius = pivotOffset + width; + } else if (isVertical(gravity)) { + mGoneRadius = pivotOffset; + if (isTop(gravity)) { + mPivotY = -pivotOffset; + padding = paddingRect.bottom; + } else { + mPivotY = height + pivotOffset; + padding = paddingRect.top; + } + mPivotX = width / 2; + mOvershootRadius = pivotOffset + height; + } else { + mPivotX = width / 2; + mPivotY = height / 2; + mGoneRadius = 0; + if (width > height) { + mOvershootRadius = height; + padding = Math.max(paddingRect.top, paddingRect.bottom); + } else { + mOvershootRadius = width; + padding = Math.max(paddingRect.left, paddingRect.right); + } + } + mRestingRadius = mOvershootRadius - padding; + mBumpRadius = mOvershootRadius - padding / 3; + mAnimationsReady = true; + } + + boolean isOpen() { + return mCircleState != CircleState.GONE; + } + + void animateOpen() { + if (!mAnimationsReady || mCircleState == CircleState.ENTERING) { + return; + } + + AnimatorSet set = new AnimatorSet(); + List animators = new ArrayList<>(); + switch (mCircleState) { + case CircleState.ENTERING: + throw new AssertionError("Should not be able to reach this statement"); + case CircleState.GONE: { + Animator startCircle = createEnterAnimator(); + markState(startCircle, CircleState.ENTERING); + animators.add(startCircle); + Animator holdOvershoot = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, mOvershootRadius, + mOvershootRadius); + holdOvershoot.setDuration(50); + markState(holdOvershoot, CircleState.OVERSHOT); + animators.add(holdOvershoot); + Animator rest = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, mOvershootRadius, + mRestingRadius); + markState(rest, CircleState.RESTING); + animators.add(rest); + Animator holdRest = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, mRestingRadius, + mRestingRadius); + markState(holdRest, CircleState.RESTED); + holdRest.setDuration(1000); + animators.add(holdRest); + Animator exit = createExitAnimator(mRestingRadius); + markState(exit, CircleState.EXITING); + animators.add(exit); + } + break; + case CircleState.RESTED: + case CircleState.RESTING: + case CircleState.EXITING: + case CircleState.OVERSHOT: + int startRadius = + mCircleState == CircleState.OVERSHOT ? mOvershootRadius : mRestingRadius; + Animator bump = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, startRadius, + mBumpRadius); + bump.setDuration(50); + markState(bump, CircleState.BUMPING); + animators.add(bump); + // fallthrough intentional + case CircleState.BUMPED: + case CircleState.BUMPING: + Animator holdBump = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, mBumpRadius, + mBumpRadius); + holdBump.setDuration(100); + markState(holdBump, CircleState.BUMPED); + animators.add(holdBump); + Animator rest = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, mBumpRadius, + mRestingRadius); + markState(rest, CircleState.RESTING); + animators.add(rest); + Animator holdRest = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, mRestingRadius, + mRestingRadius); + holdRest.setDuration(1000); + markState(holdRest, CircleState.RESTED); + animators.add(holdRest); + Animator exit = createExitAnimator(mRestingRadius); + markState(exit, CircleState.EXITING); + animators.add(exit); + break; + } + set.playSequentially(animators); + set.addListener(new AnimatorListenerAdapter() { + private boolean mCanceled = false; + + @Override + public void onAnimationStart(Animator animation) { + if (mCircularReveal != null) { + mCircularReveal.cancel(); + } + mCircularReveal = animation; + mAnimatedView.setVisibility(View.VISIBLE); + } + + @Override + public void onAnimationCancel(Animator animation) { + mCanceled = true; + } + + @Override + public void onAnimationEnd(Animator animation) { + if (mCanceled) { + return; + } + mCircularReveal = null; + mCircleState = CircleState.GONE; + mAnimatedView.setVisibility(View.GONE); + } + }); + + set.start(); + } + + private Animator createEnterAnimator() { + AnimatorSet animatorSet = new AnimatorSet(); + Animator circularReveal = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, mGoneRadius, + mOvershootRadius); + Animator fade = ObjectAnimator.ofFloat(mAnimatedView, View.ALPHA, MAX_OPACITY); + animatorSet.playTogether(circularReveal, fade); + return animatorSet; + } + + private Animator createExitAnimator(int startRadius) { + AnimatorSet animatorSet = new AnimatorSet(); + Animator circularHide = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, startRadius, + (mGoneRadius + startRadius) / 2); + circularHide.setInterpolator(ANTICIPATE_INTERPOLATOR); + Animator fade = ObjectAnimator.ofFloat(mAnimatedView, View.ALPHA, 0); + fade.setStartDelay(50); + animatorSet.playTogether(circularHide, fade); + return animatorSet; + } + + void hideCircle() { + if (!mAnimationsReady || mCircleState == CircleState.GONE + || mCircleState == CircleState.EXITING) { + return; + } + + int startRadius; + switch (mCircleState) { + // Unreachable, but here to exhaust switch cases + //noinspection ConstantConditions + case CircleState.EXITING: + //noinspection ConstantConditions + case CircleState.GONE: + throw new AssertionError("Should not be able to reach this statement"); + case CircleState.BUMPED: + case CircleState.BUMPING: + startRadius = mBumpRadius; + break; + case CircleState.OVERSHOT: + startRadius = mOvershootRadius; + break; + case CircleState.ENTERING: + case CircleState.RESTED: + case CircleState.RESTING: + startRadius = mRestingRadius; + break; + default: + throw new IllegalStateException("Unknown CircleState: " + mCircleState); + } + + Animator hideAnimation = createExitAnimator(startRadius); + if (startRadius == mRestingRadius) { + hideAnimation.setInterpolator(ANTICIPATE_INTERPOLATOR); + } + hideAnimation.addListener(new AnimatorListenerAdapter() { + private boolean mCanceled = false; + + @Override + public void onAnimationStart(Animator animation) { + mCircleState = CircleState.EXITING; + if (mCircularReveal != null) { + mCircularReveal.cancel(); + } + mCircularReveal = animation; + } + + @Override + public void onAnimationCancel(Animator animation) { + mCanceled = true; + } + + @Override + public void onAnimationEnd(Animator animation) { + if (mCanceled) { + return; + } + mCircularReveal = null; + mCircleState = CircleState.GONE; + mAnimatedView.setVisibility(View.GONE); + } + }); + hideAnimation.start(); + } + + void stopAnimations() { + if (mCircularReveal != null) { + mCircularReveal.end(); + } + } + + private void markState(Animator animator, @CircleState int startState) { + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + mCircleState = startState; + } + }); + } +} diff --git a/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureColorStore.java b/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureColorStore.java new file mode 100644 index 0000000000000..a40ffaf850c5f --- /dev/null +++ b/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureColorStore.java @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2018 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.statusbar.hvac; + +import android.graphics.Color; + +/** + * Contains the logic for mapping colors to temperatures + */ +class TemperatureColorStore { + + private static class TemperatureColorValue { + final float mTemperature; + final int mColor; + + private TemperatureColorValue(float temperature, int color) { + this.mTemperature = temperature; + this.mColor = color; + } + + float getTemperature() { + return mTemperature; + } + + int getColor() { + return mColor; + } + } + + private static TemperatureColorValue tempToColor(float temperature, int color) { + return new TemperatureColorValue(temperature, color); + } + + private static final int COLOR_COLDEST = 0xFF406DFF; + private static final int COLOR_COLD = 0xFF4094FF; + private static final int COLOR_NEUTRAL = 0xFFF4F4F4; + private static final int COLOR_WARM = 0xFFFF550F; + private static final int COLOR_WARMEST = 0xFFFF0000; + // must be sorted by temperature + private static final TemperatureColorValue[] sTemperatureColorValues = + { + // Celsius + tempToColor(19, COLOR_COLDEST), + tempToColor(21, COLOR_COLD), + tempToColor(23, COLOR_NEUTRAL), + tempToColor(25, COLOR_WARM), + tempToColor(27, COLOR_WARMEST), + + // Switch over + tempToColor(45, COLOR_WARMEST), + tempToColor(45.00001f, COLOR_COLDEST), + + // Farenheight + tempToColor(66, COLOR_COLDEST), + tempToColor(70, COLOR_COLD), + tempToColor(74, COLOR_NEUTRAL), + tempToColor(76, COLOR_WARM), + tempToColor(80, COLOR_WARMEST) + }; + + private static final int COLOR_UNSET = Color.BLACK; + + private final float[] mTempHsv1 = new float[3]; + private final float[] mTempHsv2 = new float[3]; + private final float[] mTempHsv3 = new float[3]; + + int getMinColor() { + return COLOR_COLDEST; + } + + int getMaxColor() { + return COLOR_WARMEST; + } + + int getColorForTemperature(float temperature) { + if (Float.isNaN(temperature)) { + return COLOR_UNSET; + } + TemperatureColorValue bottomValue = sTemperatureColorValues[0]; + if (temperature <= bottomValue.getTemperature()) { + return bottomValue.getColor(); + } + TemperatureColorValue topValue = + sTemperatureColorValues[sTemperatureColorValues.length - 1]; + if (temperature >= topValue.getTemperature()) { + return topValue.getColor(); + } + + int index = binarySearch(temperature); + if (index >= 0) { + return sTemperatureColorValues[index].getColor(); + } + + index = -index - 1; // move to the insertion point + + TemperatureColorValue startValue = sTemperatureColorValues[index - 1]; + TemperatureColorValue endValue = sTemperatureColorValues[index]; + float fraction = (temperature - startValue.getTemperature()) / (endValue.getTemperature() + - startValue.getTemperature()); + return lerpColor(fraction, startValue.getColor(), endValue.getColor()); + } + + int lerpColor(float fraction, int startColor, int endColor) { + float[] startHsv = mTempHsv1; + Color.colorToHSV(startColor, startHsv); + float[] endHsv = mTempHsv2; + Color.colorToHSV(endColor, endHsv); + + // If a target color is white/gray, it should use the same hue as the other target + if (startHsv[1] == 0) { + startHsv[0] = endHsv[0]; + } + if (endHsv[1] == 0) { + endHsv[0] = startHsv[0]; + } + + float[] outColor = mTempHsv3; + outColor[0] = hueLerp(fraction, startHsv[0], endHsv[0]); + outColor[1] = lerp(fraction, startHsv[1], endHsv[1]); + outColor[2] = lerp(fraction, startHsv[2], endHsv[2]); + + return Color.HSVToColor(outColor); + } + + private float hueLerp(float fraction, float start, float end) { + // If in flat part of curve, no interpolation necessary + if (start == end) { + return start; + } + + // If the hues are more than 180 degrees apart, go the other way around the color wheel + // by moving the smaller value above 360 + if (Math.abs(start - end) > 180f) { + if (start < end) { + start += 360f; + } else { + end += 360f; + } + } + // Lerp and ensure the final output is within [0, 360) + return lerp(fraction, start, end) % 360f; + + } + + private float lerp(float fraction, float start, float end) { + // If in flat part of curve, no interpolation necessary + if (start == end) { + return start; + } + + // If outside bounds, use boundary value + if (fraction >= 1) { + return end; + } + if (fraction <= 0) { + return start; + } + + return (end - start) * fraction + start; + } + + private int binarySearch(float temperature) { + int low = 0; + int high = sTemperatureColorValues.length; + + while (low <= high) { + int mid = (low + high) >>> 1; + float midVal = sTemperatureColorValues[mid].getTemperature(); + + if (midVal < temperature) { + low = mid + 1; // Neither val is NaN, thisVal is smaller + } else if (midVal > temperature) { + high = mid - 1; // Neither val is NaN, thisVal is larger + } else { + int midBits = Float.floatToIntBits(midVal); + int keyBits = Float.floatToIntBits(temperature); + if (midBits == keyBits) { // Values are equal + return mid; // Key found + } else if (midBits < keyBits) { // (-0.0, 0.0) or (!NaN, NaN) + low = mid + 1; + } else { /* (0.0, -0.0) or (NaN, !NaN)*/ + high = mid - 1; + } + } + } + return -(low + 1); // key not found. + } +} diff --git a/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureTextAnimator.java b/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureTextAnimator.java new file mode 100644 index 0000000000000..8ee5ef6badc33 --- /dev/null +++ b/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureTextAnimator.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2018 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.statusbar.hvac; + +import static com.android.systemui.statusbar.hvac.AnimatedTemperatureView.isHorizontal; +import static com.android.systemui.statusbar.hvac.AnimatedTemperatureView.isLeft; + +import android.annotation.NonNull; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.RotateAnimation; +import android.view.animation.TranslateAnimation; +import android.widget.TextSwitcher; + +/** + * Controls animating TemperatureView's text + */ +class TemperatureTextAnimator { + + private static final DecelerateInterpolator DECELERATE_INTERPOLATOR = + new DecelerateInterpolator(); + private static final AccelerateDecelerateInterpolator ACCELERATE_DECELERATE_INTERPOLATOR = + new AccelerateDecelerateInterpolator(); + + private static final int ROTATION_DEGREES = 15; + private static final int DURATION_MILLIS = 200; + + private AnimatedTemperatureView mParent; + private final TextSwitcher mTextSwitcher; + private final String mTempFormat; + private final int mPivotOffset; + private final CharSequence mMinText; + private final CharSequence mMaxText; + + private Animation mTextInAnimationUp; + private Animation mTextOutAnimationUp; + private Animation mTextInAnimationDown; + private Animation mTextOutAnimationDown; + private Animation mTextFadeInAnimation; + private Animation mTextFadeOutAnimation; + + private float mLastTemp = Float.NaN; + + TemperatureTextAnimator(AnimatedTemperatureView parent, TextSwitcher textSwitcher, + String tempFormat, int pivotOffset, + CharSequence minText, CharSequence maxText) { + mParent = parent; + mTextSwitcher = textSwitcher; + mTempFormat = tempFormat; + mPivotOffset = pivotOffset; + mMinText = minText; + mMaxText = maxText; + + mParent.addOnLayoutChangeListener( + (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> + setupAnimations(mParent.getGravity())); + } + + void setTemp(float temp) { + if (Float.isNaN(temp)) { + mTextSwitcher.setInAnimation(mTextFadeInAnimation); + mTextSwitcher.setOutAnimation(mTextFadeOutAnimation); + mTextSwitcher.setText("--"); + mLastTemp = temp; + return; + } + boolean isMinValue = mParent.isMinValue(temp); + boolean isMaxValue = mParent.isMaxValue(temp); + if (Float.isNaN(mLastTemp)) { + mTextSwitcher.setInAnimation(mTextFadeInAnimation); + mTextSwitcher.setOutAnimation(mTextFadeOutAnimation); + } else if (!isMinValue && (isMaxValue || temp > mLastTemp)) { + mTextSwitcher.setInAnimation(mTextInAnimationUp); + mTextSwitcher.setOutAnimation(mTextOutAnimationUp); + } else { + mTextSwitcher.setInAnimation(mTextInAnimationDown); + mTextSwitcher.setOutAnimation(mTextOutAnimationDown); + } + CharSequence text; + if (isMinValue) { + text = mMinText; + } else if (isMaxValue) { + text = mMaxText; + } else { + text = String.format(mTempFormat, temp); + } + mTextSwitcher.setText(text); + mLastTemp = temp; + } + + private void setupAnimations(int gravity) { + mTextFadeInAnimation = createFadeAnimation(true); + mTextFadeOutAnimation = createFadeAnimation(false); + if (!isHorizontal(gravity)) { + mTextInAnimationUp = createTranslateFadeAnimation(true, true); + mTextOutAnimationUp = createTranslateFadeAnimation(false, true); + mTextInAnimationDown = createTranslateFadeAnimation(true, false); + mTextOutAnimationDown = createTranslateFadeAnimation(false, false); + } else { + boolean isLeft = isLeft(gravity, mTextSwitcher.getLayoutDirection()); + mTextInAnimationUp = createRotateFadeAnimation(true, isLeft, true); + mTextOutAnimationUp = createRotateFadeAnimation(false, isLeft, true); + mTextInAnimationDown = createRotateFadeAnimation(true, isLeft, false); + mTextOutAnimationDown = createRotateFadeAnimation(false, isLeft, false); + } + } + + @NonNull + private Animation createFadeAnimation(boolean in) { + AnimationSet set = new AnimationSet(true); + AlphaAnimation alphaAnimation = new AlphaAnimation(in ? 0 : 1, in ? 1 : 0); + alphaAnimation.setDuration(DURATION_MILLIS); + set.addAnimation(new RotateAnimation(0, 0)); // Undo any previous rotation + set.addAnimation(alphaAnimation); + return set; + } + + @NonNull + private Animation createTranslateFadeAnimation(boolean in, boolean up) { + AnimationSet set = new AnimationSet(true); + set.setInterpolator(ACCELERATE_DECELERATE_INTERPOLATOR); + set.setDuration(DURATION_MILLIS); + int fromYDelta = in ? (up ? 1 : -1) : 0; + int toYDelta = in ? 0 : (up ? -1 : 1); + set.addAnimation( + new TranslateAnimation(Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, fromYDelta, Animation.RELATIVE_TO_SELF, + toYDelta)); + set.addAnimation(new AlphaAnimation(in ? 0 : 1, in ? 1 : 0)); + return set; + } + + @NonNull + private Animation createRotateFadeAnimation(boolean in, boolean isLeft, boolean up) { + AnimationSet set = new AnimationSet(true); + set.setInterpolator(DECELERATE_INTERPOLATOR); + set.setDuration(DURATION_MILLIS); + + float degrees = isLeft == up ? -ROTATION_DEGREES : ROTATION_DEGREES; + int pivotX = isLeft ? -mPivotOffset : mParent.getWidth() + mPivotOffset; + set.addAnimation( + new RotateAnimation(in ? -degrees : 0f, in ? 0f : degrees, Animation.ABSOLUTE, + pivotX, Animation.ABSOLUTE, 0f)); + set.addAnimation(new AlphaAnimation(in ? 0 : 1, in ? 1 : 0)); + return set; + } +}
+ * Note: It registers itself with {@link HvacController} + */ +public class AnimatedTemperatureView extends FrameLayout implements TemperatureView { + + private static final float TEMPERATURE_EQUIVALENT_DELTA = .01f; + private static final Property COLOR_PROPERTY = + new Property(Integer.class, "color") { + + @Override + public Integer get(ColorDrawable object) { + return object.getColor(); + } + + @Override + public void set(ColorDrawable object, Integer value) { + object.setColor(value); + } + }; + + static boolean isHorizontal(int gravity) { + return Gravity.isHorizontal(gravity) + && (gravity & Gravity.HORIZONTAL_GRAVITY_MASK) != Gravity.CENTER_HORIZONTAL; + } + + @SuppressLint("RtlHardcoded") + static boolean isLeft(int gravity, int layoutDirection) { + return Gravity + .getAbsoluteGravity(gravity & Gravity.HORIZONTAL_GRAVITY_MASK, layoutDirection) + == Gravity.LEFT; + } + + static boolean isVertical(int gravity) { + return Gravity.isVertical(gravity) + && (gravity & Gravity.VERTICAL_GRAVITY_MASK) != Gravity.CENTER_VERTICAL; + } + + static boolean isTop(int gravity) { + return (gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.TOP; + } + + private final int mAreaId; + private final int mPropertyId; + private final int mPivotOffset; + private final int mGravity; + private final int mTextAppearanceRes; + private final int mMinEms; + private final Rect mPaddingRect; + private final float mMinValue; + private final float mMaxValue; + + private final ColorDrawable mBackgroundColor; + + private final TemperatureColorStore mColorStore = new TemperatureColorStore(); + private final TemperatureBackgroundAnimator mBackgroundAnimator; + private final TemperatureTextAnimator mTextAnimator; + + public AnimatedTemperatureView(Context context, AttributeSet attrs) { + super(context, attrs); + TypedArray typedArray = context.obtainStyledAttributes(attrs, + R.styleable.AnimatedTemperatureView); + mAreaId = typedArray.getInt(R.styleable.AnimatedTemperatureView_hvacAreaId, -1); + mPropertyId = typedArray.getInt(R.styleable.AnimatedTemperatureView_hvacPropertyId, -1); + mPivotOffset = + typedArray.getDimensionPixelOffset( + R.styleable.AnimatedTemperatureView_hvacPivotOffset, 0); + mGravity = typedArray.getInt(R.styleable.AnimatedTemperatureView_android_gravity, + Gravity.START); + mTextAppearanceRes = + typedArray.getResourceId(R.styleable.AnimatedTemperatureView_android_textAppearance, + 0); + mMinEms = typedArray.getInteger(R.styleable.AnimatedTemperatureView_android_minEms, 0); + mMinValue = typedArray.getFloat(R.styleable.AnimatedTemperatureView_hvacMinValue, + Float.NaN); + mMaxValue = typedArray.getFloat(R.styleable.AnimatedTemperatureView_hvacMaxValue, + Float.NaN); + + + mPaddingRect = + new Rect(getPaddingLeft(), getPaddingTop(), getPaddingRight(), getPaddingBottom()); + setPadding(0, 0, 0, 0); + + setClipChildren(false); + setClipToPadding(false); + + // init Views + TextSwitcher textSwitcher = new TextSwitcher(context); + textSwitcher.setFactory(this::generateTextView); + ImageView background = new ImageView(context); + mBackgroundColor = new ColorDrawable(Color.TRANSPARENT); + background.setImageDrawable(mBackgroundColor); + background.setVisibility(View.GONE); + + mBackgroundAnimator = new TemperatureBackgroundAnimator(this, background); + + + String format = typedArray.getString(R.styleable.AnimatedTemperatureView_hvacTempFormat); + format = (format == null) ? "%.1f\u00B0" : format; + CharSequence minText = typedArray.getString( + R.styleable.AnimatedTemperatureView_hvacMinText); + CharSequence maxText = typedArray.getString( + R.styleable.AnimatedTemperatureView_hvacMaxText); + mTextAnimator = new TemperatureTextAnimator(this, textSwitcher, format, mPivotOffset, + minText, maxText); + + addView(background, ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + addView(textSwitcher, ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + + typedArray.recycle(); + + // register with controller + HvacController hvacController = Dependency.get(HvacController.class); + hvacController.addHvacTextView(this); + } + + private TextView generateTextView() { + TextView textView = new TextView(getContext()); + textView.setTextAppearance(mTextAppearanceRes); + textView.setAllCaps(true); + textView.setMinEms(mMinEms); + textView.setGravity(mGravity); + textView.setPadding(mPaddingRect.left, mPaddingRect.top, mPaddingRect.right, + mPaddingRect.bottom); + textView.getViewTreeObserver() + .addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + if (isHorizontal(mGravity)) { + if (isLeft(mGravity, getLayoutDirection())) { + textView.setPivotX(-mPivotOffset); + } else { + textView.setPivotX(textView.getWidth() + mPivotOffset); + } + } + textView.getViewTreeObserver().removeOnPreDrawListener(this); + return false; + } + }); + textView.setLayoutParams(new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + + return textView; + } + + /** + * Formats the float for display + * + * @param temp - The current temp or NaN + */ + @Override + public void setTemp(float temp) { + mTextAnimator.setTemp(temp); + if (Float.isNaN(temp)) { + mBackgroundAnimator.hideCircle(); + return; + } + int color; + if (isMinValue(temp)) { + color = mColorStore.getMinColor(); + } else if (isMaxValue(temp)) { + color = mColorStore.getMaxColor(); + } else { + color = mColorStore.getColorForTemperature(temp); + } + if (mBackgroundAnimator.isOpen()) { + ObjectAnimator colorAnimator = + ObjectAnimator.ofInt(mBackgroundColor, COLOR_PROPERTY, color); + colorAnimator.setEvaluator((fraction, startValue, endValue) -> mColorStore + .lerpColor(fraction, (int) startValue, (int) endValue)); + colorAnimator.start(); + } else { + mBackgroundColor.setColor(color); + } + + mBackgroundAnimator.animateOpen(); + } + + boolean isMinValue(float temp) { + return !Float.isNaN(mMinValue) && isApproxEqual(temp, mMinValue); + } + + boolean isMaxValue(float temp) { + return !Float.isNaN(mMaxValue) && isApproxEqual(temp, mMaxValue); + } + + private boolean isApproxEqual(float left, float right) { + return Math.abs(left - right) <= TEMPERATURE_EQUIVALENT_DELTA; + } + + int getGravity() { + return mGravity; + } + + int getPivotOffset() { + return mPivotOffset; + } + + Rect getPaddingRect() { + return mPaddingRect; + } + + /** + * @return propertiyId Example: CarHvacManager.ID_ZONED_TEMP_SETPOINT (16385) + */ + @Override + public int getPropertyId() { + return mPropertyId; + } + + /** + * @return hvac AreaId - Example: VehicleSeat.SEAT_ROW_1_LEFT (1) + */ + @Override + public int getAreaId() { + return mAreaId; + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mBackgroundAnimator.stopAnimations(); + } + +} + diff --git a/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureBackgroundAnimator.java b/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureBackgroundAnimator.java new file mode 100644 index 0000000000000..0bc94b5003878 --- /dev/null +++ b/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureBackgroundAnimator.java @@ -0,0 +1,338 @@ +/* + * Copyright (c) 2018 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.statusbar.hvac; + +import static com.android.systemui.statusbar.hvac.AnimatedTemperatureView.isHorizontal; +import static com.android.systemui.statusbar.hvac.AnimatedTemperatureView.isLeft; +import static com.android.systemui.statusbar.hvac.AnimatedTemperatureView.isTop; +import static com.android.systemui.statusbar.hvac.AnimatedTemperatureView.isVertical; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.annotation.IntDef; +import android.graphics.Rect; +import android.view.View; +import android.view.ViewAnimationUtils; +import android.view.animation.AnticipateInterpolator; +import android.widget.ImageView; + +import java.util.ArrayList; +import java.util.List; + +/** + * Controls circular reveal animation of temperature background + */ +class TemperatureBackgroundAnimator { + + private static final AnticipateInterpolator ANTICIPATE_INTERPOLATOR = + new AnticipateInterpolator(); + private static final float MAX_OPACITY = .6f; + + private final View mAnimatedView; + + private int mPivotX; + private int mPivotY; + private int mGoneRadius; + private int mOvershootRadius; + private int mRestingRadius; + private int mBumpRadius; + + @CircleState + private int mCircleState; + + private Animator mCircularReveal; + private boolean mAnimationsReady; + + @IntDef({CircleState.GONE, CircleState.ENTERING, CircleState.OVERSHOT, CircleState.RESTING, + CircleState.RESTED, CircleState.BUMPING, CircleState.BUMPED, CircleState.EXITING}) + private @interface CircleState { + int GONE = 0; + int ENTERING = 1; + int OVERSHOT = 2; + int RESTING = 3; + int RESTED = 4; + int BUMPING = 5; + int BUMPED = 6; + int EXITING = 7; + } + + TemperatureBackgroundAnimator( + AnimatedTemperatureView parent, + ImageView animatedView) { + mAnimatedView = animatedView; + mAnimatedView.setAlpha(0); + + parent.addOnLayoutChangeListener( + (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> + setupAnimations(parent.getGravity(), parent.getPivotOffset(), + parent.getPaddingRect(), parent.getWidth(), parent.getHeight())); + } + + private void setupAnimations(int gravity, int pivotOffset, Rect paddingRect, + int width, int height) { + int padding; + if (isHorizontal(gravity)) { + mGoneRadius = pivotOffset; + if (isLeft(gravity, mAnimatedView.getLayoutDirection())) { + mPivotX = -pivotOffset; + padding = paddingRect.right; + } else { + mPivotX = width + pivotOffset; + padding = paddingRect.left; + } + mPivotY = height / 2; + mOvershootRadius = pivotOffset + width; + } else if (isVertical(gravity)) { + mGoneRadius = pivotOffset; + if (isTop(gravity)) { + mPivotY = -pivotOffset; + padding = paddingRect.bottom; + } else { + mPivotY = height + pivotOffset; + padding = paddingRect.top; + } + mPivotX = width / 2; + mOvershootRadius = pivotOffset + height; + } else { + mPivotX = width / 2; + mPivotY = height / 2; + mGoneRadius = 0; + if (width > height) { + mOvershootRadius = height; + padding = Math.max(paddingRect.top, paddingRect.bottom); + } else { + mOvershootRadius = width; + padding = Math.max(paddingRect.left, paddingRect.right); + } + } + mRestingRadius = mOvershootRadius - padding; + mBumpRadius = mOvershootRadius - padding / 3; + mAnimationsReady = true; + } + + boolean isOpen() { + return mCircleState != CircleState.GONE; + } + + void animateOpen() { + if (!mAnimationsReady || mCircleState == CircleState.ENTERING) { + return; + } + + AnimatorSet set = new AnimatorSet(); + List animators = new ArrayList<>(); + switch (mCircleState) { + case CircleState.ENTERING: + throw new AssertionError("Should not be able to reach this statement"); + case CircleState.GONE: { + Animator startCircle = createEnterAnimator(); + markState(startCircle, CircleState.ENTERING); + animators.add(startCircle); + Animator holdOvershoot = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, mOvershootRadius, + mOvershootRadius); + holdOvershoot.setDuration(50); + markState(holdOvershoot, CircleState.OVERSHOT); + animators.add(holdOvershoot); + Animator rest = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, mOvershootRadius, + mRestingRadius); + markState(rest, CircleState.RESTING); + animators.add(rest); + Animator holdRest = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, mRestingRadius, + mRestingRadius); + markState(holdRest, CircleState.RESTED); + holdRest.setDuration(1000); + animators.add(holdRest); + Animator exit = createExitAnimator(mRestingRadius); + markState(exit, CircleState.EXITING); + animators.add(exit); + } + break; + case CircleState.RESTED: + case CircleState.RESTING: + case CircleState.EXITING: + case CircleState.OVERSHOT: + int startRadius = + mCircleState == CircleState.OVERSHOT ? mOvershootRadius : mRestingRadius; + Animator bump = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, startRadius, + mBumpRadius); + bump.setDuration(50); + markState(bump, CircleState.BUMPING); + animators.add(bump); + // fallthrough intentional + case CircleState.BUMPED: + case CircleState.BUMPING: + Animator holdBump = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, mBumpRadius, + mBumpRadius); + holdBump.setDuration(100); + markState(holdBump, CircleState.BUMPED); + animators.add(holdBump); + Animator rest = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, mBumpRadius, + mRestingRadius); + markState(rest, CircleState.RESTING); + animators.add(rest); + Animator holdRest = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, mRestingRadius, + mRestingRadius); + holdRest.setDuration(1000); + markState(holdRest, CircleState.RESTED); + animators.add(holdRest); + Animator exit = createExitAnimator(mRestingRadius); + markState(exit, CircleState.EXITING); + animators.add(exit); + break; + } + set.playSequentially(animators); + set.addListener(new AnimatorListenerAdapter() { + private boolean mCanceled = false; + + @Override + public void onAnimationStart(Animator animation) { + if (mCircularReveal != null) { + mCircularReveal.cancel(); + } + mCircularReveal = animation; + mAnimatedView.setVisibility(View.VISIBLE); + } + + @Override + public void onAnimationCancel(Animator animation) { + mCanceled = true; + } + + @Override + public void onAnimationEnd(Animator animation) { + if (mCanceled) { + return; + } + mCircularReveal = null; + mCircleState = CircleState.GONE; + mAnimatedView.setVisibility(View.GONE); + } + }); + + set.start(); + } + + private Animator createEnterAnimator() { + AnimatorSet animatorSet = new AnimatorSet(); + Animator circularReveal = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, mGoneRadius, + mOvershootRadius); + Animator fade = ObjectAnimator.ofFloat(mAnimatedView, View.ALPHA, MAX_OPACITY); + animatorSet.playTogether(circularReveal, fade); + return animatorSet; + } + + private Animator createExitAnimator(int startRadius) { + AnimatorSet animatorSet = new AnimatorSet(); + Animator circularHide = ViewAnimationUtils + .createCircularReveal(mAnimatedView, mPivotX, mPivotY, startRadius, + (mGoneRadius + startRadius) / 2); + circularHide.setInterpolator(ANTICIPATE_INTERPOLATOR); + Animator fade = ObjectAnimator.ofFloat(mAnimatedView, View.ALPHA, 0); + fade.setStartDelay(50); + animatorSet.playTogether(circularHide, fade); + return animatorSet; + } + + void hideCircle() { + if (!mAnimationsReady || mCircleState == CircleState.GONE + || mCircleState == CircleState.EXITING) { + return; + } + + int startRadius; + switch (mCircleState) { + // Unreachable, but here to exhaust switch cases + //noinspection ConstantConditions + case CircleState.EXITING: + //noinspection ConstantConditions + case CircleState.GONE: + throw new AssertionError("Should not be able to reach this statement"); + case CircleState.BUMPED: + case CircleState.BUMPING: + startRadius = mBumpRadius; + break; + case CircleState.OVERSHOT: + startRadius = mOvershootRadius; + break; + case CircleState.ENTERING: + case CircleState.RESTED: + case CircleState.RESTING: + startRadius = mRestingRadius; + break; + default: + throw new IllegalStateException("Unknown CircleState: " + mCircleState); + } + + Animator hideAnimation = createExitAnimator(startRadius); + if (startRadius == mRestingRadius) { + hideAnimation.setInterpolator(ANTICIPATE_INTERPOLATOR); + } + hideAnimation.addListener(new AnimatorListenerAdapter() { + private boolean mCanceled = false; + + @Override + public void onAnimationStart(Animator animation) { + mCircleState = CircleState.EXITING; + if (mCircularReveal != null) { + mCircularReveal.cancel(); + } + mCircularReveal = animation; + } + + @Override + public void onAnimationCancel(Animator animation) { + mCanceled = true; + } + + @Override + public void onAnimationEnd(Animator animation) { + if (mCanceled) { + return; + } + mCircularReveal = null; + mCircleState = CircleState.GONE; + mAnimatedView.setVisibility(View.GONE); + } + }); + hideAnimation.start(); + } + + void stopAnimations() { + if (mCircularReveal != null) { + mCircularReveal.end(); + } + } + + private void markState(Animator animator, @CircleState int startState) { + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + mCircleState = startState; + } + }); + } +} diff --git a/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureColorStore.java b/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureColorStore.java new file mode 100644 index 0000000000000..a40ffaf850c5f --- /dev/null +++ b/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureColorStore.java @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2018 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.statusbar.hvac; + +import android.graphics.Color; + +/** + * Contains the logic for mapping colors to temperatures + */ +class TemperatureColorStore { + + private static class TemperatureColorValue { + final float mTemperature; + final int mColor; + + private TemperatureColorValue(float temperature, int color) { + this.mTemperature = temperature; + this.mColor = color; + } + + float getTemperature() { + return mTemperature; + } + + int getColor() { + return mColor; + } + } + + private static TemperatureColorValue tempToColor(float temperature, int color) { + return new TemperatureColorValue(temperature, color); + } + + private static final int COLOR_COLDEST = 0xFF406DFF; + private static final int COLOR_COLD = 0xFF4094FF; + private static final int COLOR_NEUTRAL = 0xFFF4F4F4; + private static final int COLOR_WARM = 0xFFFF550F; + private static final int COLOR_WARMEST = 0xFFFF0000; + // must be sorted by temperature + private static final TemperatureColorValue[] sTemperatureColorValues = + { + // Celsius + tempToColor(19, COLOR_COLDEST), + tempToColor(21, COLOR_COLD), + tempToColor(23, COLOR_NEUTRAL), + tempToColor(25, COLOR_WARM), + tempToColor(27, COLOR_WARMEST), + + // Switch over + tempToColor(45, COLOR_WARMEST), + tempToColor(45.00001f, COLOR_COLDEST), + + // Farenheight + tempToColor(66, COLOR_COLDEST), + tempToColor(70, COLOR_COLD), + tempToColor(74, COLOR_NEUTRAL), + tempToColor(76, COLOR_WARM), + tempToColor(80, COLOR_WARMEST) + }; + + private static final int COLOR_UNSET = Color.BLACK; + + private final float[] mTempHsv1 = new float[3]; + private final float[] mTempHsv2 = new float[3]; + private final float[] mTempHsv3 = new float[3]; + + int getMinColor() { + return COLOR_COLDEST; + } + + int getMaxColor() { + return COLOR_WARMEST; + } + + int getColorForTemperature(float temperature) { + if (Float.isNaN(temperature)) { + return COLOR_UNSET; + } + TemperatureColorValue bottomValue = sTemperatureColorValues[0]; + if (temperature <= bottomValue.getTemperature()) { + return bottomValue.getColor(); + } + TemperatureColorValue topValue = + sTemperatureColorValues[sTemperatureColorValues.length - 1]; + if (temperature >= topValue.getTemperature()) { + return topValue.getColor(); + } + + int index = binarySearch(temperature); + if (index >= 0) { + return sTemperatureColorValues[index].getColor(); + } + + index = -index - 1; // move to the insertion point + + TemperatureColorValue startValue = sTemperatureColorValues[index - 1]; + TemperatureColorValue endValue = sTemperatureColorValues[index]; + float fraction = (temperature - startValue.getTemperature()) / (endValue.getTemperature() + - startValue.getTemperature()); + return lerpColor(fraction, startValue.getColor(), endValue.getColor()); + } + + int lerpColor(float fraction, int startColor, int endColor) { + float[] startHsv = mTempHsv1; + Color.colorToHSV(startColor, startHsv); + float[] endHsv = mTempHsv2; + Color.colorToHSV(endColor, endHsv); + + // If a target color is white/gray, it should use the same hue as the other target + if (startHsv[1] == 0) { + startHsv[0] = endHsv[0]; + } + if (endHsv[1] == 0) { + endHsv[0] = startHsv[0]; + } + + float[] outColor = mTempHsv3; + outColor[0] = hueLerp(fraction, startHsv[0], endHsv[0]); + outColor[1] = lerp(fraction, startHsv[1], endHsv[1]); + outColor[2] = lerp(fraction, startHsv[2], endHsv[2]); + + return Color.HSVToColor(outColor); + } + + private float hueLerp(float fraction, float start, float end) { + // If in flat part of curve, no interpolation necessary + if (start == end) { + return start; + } + + // If the hues are more than 180 degrees apart, go the other way around the color wheel + // by moving the smaller value above 360 + if (Math.abs(start - end) > 180f) { + if (start < end) { + start += 360f; + } else { + end += 360f; + } + } + // Lerp and ensure the final output is within [0, 360) + return lerp(fraction, start, end) % 360f; + + } + + private float lerp(float fraction, float start, float end) { + // If in flat part of curve, no interpolation necessary + if (start == end) { + return start; + } + + // If outside bounds, use boundary value + if (fraction >= 1) { + return end; + } + if (fraction <= 0) { + return start; + } + + return (end - start) * fraction + start; + } + + private int binarySearch(float temperature) { + int low = 0; + int high = sTemperatureColorValues.length; + + while (low <= high) { + int mid = (low + high) >>> 1; + float midVal = sTemperatureColorValues[mid].getTemperature(); + + if (midVal < temperature) { + low = mid + 1; // Neither val is NaN, thisVal is smaller + } else if (midVal > temperature) { + high = mid - 1; // Neither val is NaN, thisVal is larger + } else { + int midBits = Float.floatToIntBits(midVal); + int keyBits = Float.floatToIntBits(temperature); + if (midBits == keyBits) { // Values are equal + return mid; // Key found + } else if (midBits < keyBits) { // (-0.0, 0.0) or (!NaN, NaN) + low = mid + 1; + } else { /* (0.0, -0.0) or (NaN, !NaN)*/ + high = mid - 1; + } + } + } + return -(low + 1); // key not found. + } +} diff --git a/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureTextAnimator.java b/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureTextAnimator.java new file mode 100644 index 0000000000000..8ee5ef6badc33 --- /dev/null +++ b/packages/CarSystemUI/src/com/android/systemui/statusbar/hvac/TemperatureTextAnimator.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2018 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.statusbar.hvac; + +import static com.android.systemui.statusbar.hvac.AnimatedTemperatureView.isHorizontal; +import static com.android.systemui.statusbar.hvac.AnimatedTemperatureView.isLeft; + +import android.annotation.NonNull; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.RotateAnimation; +import android.view.animation.TranslateAnimation; +import android.widget.TextSwitcher; + +/** + * Controls animating TemperatureView's text + */ +class TemperatureTextAnimator { + + private static final DecelerateInterpolator DECELERATE_INTERPOLATOR = + new DecelerateInterpolator(); + private static final AccelerateDecelerateInterpolator ACCELERATE_DECELERATE_INTERPOLATOR = + new AccelerateDecelerateInterpolator(); + + private static final int ROTATION_DEGREES = 15; + private static final int DURATION_MILLIS = 200; + + private AnimatedTemperatureView mParent; + private final TextSwitcher mTextSwitcher; + private final String mTempFormat; + private final int mPivotOffset; + private final CharSequence mMinText; + private final CharSequence mMaxText; + + private Animation mTextInAnimationUp; + private Animation mTextOutAnimationUp; + private Animation mTextInAnimationDown; + private Animation mTextOutAnimationDown; + private Animation mTextFadeInAnimation; + private Animation mTextFadeOutAnimation; + + private float mLastTemp = Float.NaN; + + TemperatureTextAnimator(AnimatedTemperatureView parent, TextSwitcher textSwitcher, + String tempFormat, int pivotOffset, + CharSequence minText, CharSequence maxText) { + mParent = parent; + mTextSwitcher = textSwitcher; + mTempFormat = tempFormat; + mPivotOffset = pivotOffset; + mMinText = minText; + mMaxText = maxText; + + mParent.addOnLayoutChangeListener( + (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> + setupAnimations(mParent.getGravity())); + } + + void setTemp(float temp) { + if (Float.isNaN(temp)) { + mTextSwitcher.setInAnimation(mTextFadeInAnimation); + mTextSwitcher.setOutAnimation(mTextFadeOutAnimation); + mTextSwitcher.setText("--"); + mLastTemp = temp; + return; + } + boolean isMinValue = mParent.isMinValue(temp); + boolean isMaxValue = mParent.isMaxValue(temp); + if (Float.isNaN(mLastTemp)) { + mTextSwitcher.setInAnimation(mTextFadeInAnimation); + mTextSwitcher.setOutAnimation(mTextFadeOutAnimation); + } else if (!isMinValue && (isMaxValue || temp > mLastTemp)) { + mTextSwitcher.setInAnimation(mTextInAnimationUp); + mTextSwitcher.setOutAnimation(mTextOutAnimationUp); + } else { + mTextSwitcher.setInAnimation(mTextInAnimationDown); + mTextSwitcher.setOutAnimation(mTextOutAnimationDown); + } + CharSequence text; + if (isMinValue) { + text = mMinText; + } else if (isMaxValue) { + text = mMaxText; + } else { + text = String.format(mTempFormat, temp); + } + mTextSwitcher.setText(text); + mLastTemp = temp; + } + + private void setupAnimations(int gravity) { + mTextFadeInAnimation = createFadeAnimation(true); + mTextFadeOutAnimation = createFadeAnimation(false); + if (!isHorizontal(gravity)) { + mTextInAnimationUp = createTranslateFadeAnimation(true, true); + mTextOutAnimationUp = createTranslateFadeAnimation(false, true); + mTextInAnimationDown = createTranslateFadeAnimation(true, false); + mTextOutAnimationDown = createTranslateFadeAnimation(false, false); + } else { + boolean isLeft = isLeft(gravity, mTextSwitcher.getLayoutDirection()); + mTextInAnimationUp = createRotateFadeAnimation(true, isLeft, true); + mTextOutAnimationUp = createRotateFadeAnimation(false, isLeft, true); + mTextInAnimationDown = createRotateFadeAnimation(true, isLeft, false); + mTextOutAnimationDown = createRotateFadeAnimation(false, isLeft, false); + } + } + + @NonNull + private Animation createFadeAnimation(boolean in) { + AnimationSet set = new AnimationSet(true); + AlphaAnimation alphaAnimation = new AlphaAnimation(in ? 0 : 1, in ? 1 : 0); + alphaAnimation.setDuration(DURATION_MILLIS); + set.addAnimation(new RotateAnimation(0, 0)); // Undo any previous rotation + set.addAnimation(alphaAnimation); + return set; + } + + @NonNull + private Animation createTranslateFadeAnimation(boolean in, boolean up) { + AnimationSet set = new AnimationSet(true); + set.setInterpolator(ACCELERATE_DECELERATE_INTERPOLATOR); + set.setDuration(DURATION_MILLIS); + int fromYDelta = in ? (up ? 1 : -1) : 0; + int toYDelta = in ? 0 : (up ? -1 : 1); + set.addAnimation( + new TranslateAnimation(Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, fromYDelta, Animation.RELATIVE_TO_SELF, + toYDelta)); + set.addAnimation(new AlphaAnimation(in ? 0 : 1, in ? 1 : 0)); + return set; + } + + @NonNull + private Animation createRotateFadeAnimation(boolean in, boolean isLeft, boolean up) { + AnimationSet set = new AnimationSet(true); + set.setInterpolator(DECELERATE_INTERPOLATOR); + set.setDuration(DURATION_MILLIS); + + float degrees = isLeft == up ? -ROTATION_DEGREES : ROTATION_DEGREES; + int pivotX = isLeft ? -mPivotOffset : mParent.getWidth() + mPivotOffset; + set.addAnimation( + new RotateAnimation(in ? -degrees : 0f, in ? 0f : degrees, Animation.ABSOLUTE, + pivotX, Animation.ABSOLUTE, 0f)); + set.addAnimation(new AlphaAnimation(in ? 0 : 1, in ? 1 : 0)); + return set; + } +}