1 /* 2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.systemui.tv.media.settings; 18 19 import android.content.Context; 20 import android.graphics.Rect; 21 import android.graphics.drawable.Drawable; 22 import android.os.CountDownTimer; 23 import android.util.AttributeSet; 24 import android.util.Log; 25 import android.view.Gravity; 26 import android.view.KeyEvent; 27 import android.view.View; 28 import android.view.ViewGroup; 29 import android.view.ViewTreeObserver; 30 import android.widget.FrameLayout; 31 import android.widget.ImageView; 32 import android.widget.PopupWindow; 33 import android.widget.TextView; 34 35 import androidx.annotation.Nullable; 36 37 import java.util.Objects; 38 39 import com.android.systemui.tv.res.R; 40 41 /** 42 * Base control widget that has default tooltip functionality. 43 **/ 44 public class ControlWidget extends FrameLayout 45 implements View.OnFocusChangeListener, View.OnKeyListener { 46 private static final String TAG = ControlWidget.class.getSimpleName(); 47 private static final boolean DEBUG = false; 48 49 private static final int TOOLTIP_DELAY_MS = 2000; 50 private TooltipConfig mTooltipConfig; 51 private View mTooltipView; 52 private PopupWindow mTooltipWindow; 53 @Nullable 54 private OnKeyListener mExternalOnKeyListener; 55 @Nullable 56 private OnFocusChangeListener mExternalOnFocusChangeListener; 57 58 private final CountDownTimer mTooltipTimer; 59 ControlWidget(Context context)60 public ControlWidget(Context context) { 61 this(context, /* attrs= */ null); 62 } 63 ControlWidget(Context context, @Nullable AttributeSet attrs)64 public ControlWidget(Context context, @Nullable AttributeSet attrs) { 65 this(context, attrs, /* defStyleAttr= */ 0); 66 } 67 68 @SuppressWarnings("nullness") ControlWidget(Context context, @Nullable AttributeSet attrs, int defStyleAttr)69 public ControlWidget(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { 70 super(context, attrs, defStyleAttr); 71 super.setOnFocusChangeListener(this); 72 super.setOnKeyListener(this); 73 this.mTooltipTimer = createTooltipTimer(TOOLTIP_DELAY_MS); 74 } 75 76 @Override setEnabled(boolean enabled)77 public void setEnabled(boolean enabled) { 78 if (this.isEnabled() == enabled) { 79 return; 80 } 81 super.setEnabled(enabled); 82 setAlpha(enabled ? 1f : 0.6f); 83 } 84 85 @Override setOnKeyListener(@ullable OnKeyListener onKeyListener)86 public void setOnKeyListener(@Nullable OnKeyListener onKeyListener) { 87 this.mExternalOnKeyListener = onKeyListener; 88 } 89 90 @Override setOnFocusChangeListener(@ullable OnFocusChangeListener onFocusChangeListener)91 public void setOnFocusChangeListener(@Nullable OnFocusChangeListener onFocusChangeListener) { 92 this.mExternalOnFocusChangeListener = onFocusChangeListener; 93 } 94 setTooltipConfig(TooltipConfig tooltipConfig)95 public void setTooltipConfig(TooltipConfig tooltipConfig) { 96 if (Objects.equals(this.mTooltipConfig, tooltipConfig)) { 97 return; 98 } 99 100 this.mTooltipConfig = tooltipConfig; 101 102 if (tooltipConfig == null) { 103 dismissTooltipWindow(); 104 } else { 105 // reflect tool tip config changes 106 if (!tooltipConfig.getShouldShowTooltip()) { 107 dismissTooltipWindow(); 108 } 109 110 if (mTooltipView != null && mTooltipWindow != null && mTooltipWindow.isShowing()) { 111 loadText(); 112 loadSummary(); 113 loadImage(); 114 } 115 } 116 } 117 shouldAbortShowingTooltip()118 private boolean shouldAbortShowingTooltip() { 119 return !isFocused() || mTooltipConfig == null || !mTooltipConfig.getShouldShowTooltip() 120 || !isAttachedToWindow(); 121 } 122 showTooltipView()123 private void showTooltipView() { 124 if (shouldAbortShowingTooltip()) { 125 return; 126 } 127 int width = getContext().getResources().getDimensionPixelSize(R.dimen.tooltip_window_width); 128 129 // Construct tooltip pop-up window. 130 mTooltipView = View.inflate(this.getContext(), R.layout.tooltip_window, null); 131 mTooltipView.getViewTreeObserver().addOnGlobalLayoutListener( 132 new ViewTreeObserver.OnGlobalLayoutListener() { 133 134 @Override 135 public void onGlobalLayout() { 136 if (DEBUG) Log.d(TAG, "onGlobalLayoutListener"); 137 mTooltipView.getViewTreeObserver().removeOnGlobalLayoutListener(this); 138 if (shouldAbortShowingTooltip()) { 139 return; 140 } 141 // Calculate tooltip location on screen. 142 Rect location = locateView(ControlWidget.this); 143 int[] position = ControlWidget.this.calculateWindowOffset(location); 144 if (DEBUG) { 145 Log.d(TAG, 146 "new position, x=" + position[0] + ", y=" + position[1]); 147 } 148 149 // Only update the position, not the size 150 mTooltipWindow.update(position[0], position[1], -1, -1); 151 mTooltipView.postDelayed(() -> { 152 if (shouldAbortShowingTooltip()) { 153 return; 154 } 155 if (DEBUG) Log.d(TAG, "postDelayed, make visible"); 156 mTooltipView.setVisibility(VISIBLE); 157 }, 100); 158 } 159 }); 160 161 mTooltipWindow = new PopupWindow(mTooltipView, width, ViewGroup.LayoutParams.WRAP_CONTENT, 162 false); 163 mTooltipWindow.setAnimationStyle(R.style.ControlWidgetTooltipWindowAnimation); 164 mTooltipView.setVisibility(INVISIBLE); 165 166 // Load image and text. 167 loadImage(); 168 loadSummary(); 169 loadText(); 170 171 // Calculate tooltip location on screen. 172 Rect location = locateView(this); 173 int[] position = calculateWindowOffset(location); 174 175 // Display tooltip window. 176 mTooltipWindow.showAtLocation(this, Gravity.NO_GRAVITY, position[0], position[1]); 177 } 178 dismissTooltipWindow()179 public void dismissTooltipWindow() { 180 if (mTooltipWindow != null && mTooltipWindow.isShowing()) { 181 mTooltipWindow.dismiss(); 182 } else { 183 mTooltipTimer.cancel(); 184 } 185 } 186 locateView(View view)187 private static Rect locateView(View view) { 188 int[] locationInt = new int[2]; 189 view.getLocationOnScreen(locationInt); 190 Rect location = new Rect(); 191 location.left = locationInt[0]; 192 location.top = locationInt[1]; 193 location.right = location.left + view.getWidth(); 194 location.bottom = location.top + view.getHeight(); 195 return location; 196 } 197 calculateWindowOffset(Rect focusedRect)198 private int[] calculateWindowOffset(Rect focusedRect) { 199 int[] windowOffset = new int[2]; 200 int tooltipWidth = getContext().getResources().getDimensionPixelSize( 201 R.dimen.tooltip_window_width); 202 boolean isRtl = 203 getResources().getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; 204 // X offset 205 if (isRtl) { 206 // controlWidget.width + margin 207 windowOffset[0] = focusedRect.right 208 + getContext().getResources() 209 .getDimensionPixelOffset(R.dimen.tooltip_window_horizontal_margin); 210 } else { 211 windowOffset[0] = -tooltipWidth 212 - getContext().getResources() 213 .getDimensionPixelOffset(R.dimen.tooltip_window_horizontal_margin); 214 } 215 // Y offset 216 if (mTooltipView.getMeasuredHeight() <= 0) { 217 // Height unknown -> fixed offset 218 windowOffset[1] = focusedRect.top 219 - getContext().getResources() 220 .getDimensionPixelOffset(R.dimen.media_dialog_margin_vertical) 221 + getContext().getResources().getDimensionPixelSize( 222 R.dimen.tooltip_window_vertical_margin); 223 } else { 224 // Height known -> calculate centered position 225 windowOffset[1] = (focusedRect.top + focusedRect.bottom) / 2 226 - mTooltipView.getMeasuredHeight() / 2 227 - getContext().getResources() 228 .getDimensionPixelOffset(R.dimen.media_dialog_margin_vertical); 229 } 230 231 return windowOffset; 232 } 233 loadText()234 private void loadText() { 235 CharSequence text = mTooltipConfig.getTooltipText(); 236 TextView textView = mTooltipView.requireViewById(R.id.tooltip_text); 237 238 if (text == null || text.isEmpty()) { 239 textView.setVisibility(GONE); 240 } else { 241 textView.setVisibility(VISIBLE); 242 textView.setText(text); 243 } 244 } 245 loadSummary()246 private void loadSummary() { 247 CharSequence summary = mTooltipConfig.getTooltipSummary(); 248 TextView summaryView = mTooltipView.requireViewById(R.id.tooltip_summary); 249 250 if (summary == null || summary.isEmpty()) { 251 summaryView.setVisibility(GONE); 252 } else { 253 summaryView.setVisibility(VISIBLE); 254 summaryView.setText(summary); 255 } 256 } 257 loadImage()258 private void loadImage() { 259 ImageView tooltipImage = mTooltipView.requireViewById(R.id.tooltip_image); 260 Drawable imageDrawable = mTooltipConfig.getImageDrawable(); 261 if (imageDrawable == null) { 262 tooltipImage.setVisibility(GONE); 263 } else { 264 tooltipImage.setImageDrawable(imageDrawable); 265 tooltipImage.setVisibility(VISIBLE); 266 } 267 } 268 createTooltipTimer(long delayMs)269 private CountDownTimer createTooltipTimer(long delayMs) { 270 return new CountDownTimer(delayMs, delayMs) { 271 @Override 272 public void onTick(long millisUntilFinished) { 273 } 274 275 @Override 276 public void onFinish() { 277 showTooltipView(); 278 } 279 }; 280 } 281 282 @Override 283 public void onFocusChange(View v, boolean hasFocus) { 284 if (mExternalOnFocusChangeListener != null) { 285 mExternalOnFocusChangeListener.onFocusChange(v, hasFocus); 286 } 287 if (mTooltipConfig == null || !mTooltipConfig.getShouldShowTooltip()) { 288 return; 289 } 290 if (hasFocus) { 291 mTooltipTimer.start(); 292 } else { 293 if (mTooltipWindow != null && mTooltipWindow.isShowing()) { 294 mTooltipWindow.dismiss(); 295 } else { 296 mTooltipTimer.cancel(); 297 } 298 } 299 } 300 301 @Override 302 public boolean onKey(View v, int keyCode, KeyEvent event) { 303 if (event.getAction() == KeyEvent.ACTION_DOWN) { 304 if (mTooltipWindow != null && mTooltipWindow.isShowing()) { 305 mTooltipWindow.dismiss(); 306 } else { 307 mTooltipTimer.cancel(); 308 } 309 if (mTooltipConfig != null 310 && mTooltipConfig.getShouldShowTooltip() 311 && isFocused()) { 312 // Start the timer in case user hover 3 seconds again. 313 mTooltipTimer.start(); 314 } 315 } 316 if (mExternalOnKeyListener != null) { 317 return mExternalOnKeyListener.onKey(v, keyCode, event); 318 } 319 return false; 320 } 321 322 /** Wrapper class for callers to set tool tip related attributes. */ 323 public static final class TooltipConfig { 324 private boolean mShouldShowTooltip; 325 private Drawable mImageDrawable; 326 private CharSequence mTooltipText; 327 private CharSequence mTooltipSummary; 328 329 public void setShouldShowTooltip(boolean shouldShowTooltip) { 330 this.mShouldShowTooltip = shouldShowTooltip; 331 } 332 333 public boolean getShouldShowTooltip() { 334 return mShouldShowTooltip; 335 } 336 337 public void setImageDrawable(Drawable imageDrawable) { 338 this.mImageDrawable = imageDrawable; 339 } 340 341 public Drawable getImageDrawable() { 342 return mImageDrawable; 343 } 344 345 public void setTooltipText(CharSequence tooltipText) { 346 this.mTooltipText = tooltipText; 347 } 348 349 public CharSequence getTooltipText() { 350 return mTooltipText; 351 } 352 353 public void setTooltipSummary(CharSequence tooltipSummary) { 354 this.mTooltipSummary = tooltipSummary; 355 } 356 357 public CharSequence getTooltipSummary() { 358 return mTooltipSummary; 359 } 360 } 361 } 362