├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── owen │ │ └── focus │ │ └── example │ │ └── ExampleInstrumentedTest.java │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── owen │ │ │ └── focus │ │ │ └── example │ │ │ ├── MainActivity.java │ │ │ └── view │ │ │ ├── RoundFrameLayout.java │ │ │ └── TvHorizontalScrollView.java │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ └── activity_main.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── focus.9.png │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── colors.xml │ │ ├── round_framelayout_attrs.xml │ │ ├── scrollview_values.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── com │ └── owen │ └── focus │ └── example │ └── ExampleUnitTest.java ├── build.gradle ├── gradle.properties ├── gradlew ├── gradlew.bat ├── image └── focus3.gif ├── settings.gradle └── tv-focusborder ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src └── main ├── AndroidManifest.xml ├── java └── com │ └── owen │ └── focus │ ├── AbsFocusBorder.java │ ├── ColorFocusBorder.java │ ├── DrawableFocusBorder.java │ └── FocusBorder.java └── res └── values └── strings.xml /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | /gradle 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 欢迎使用Android TV端焦点框框架 TvFocusBorder [ ![Download](https://api.bintray.com/packages/zhousuqiang/maven/tv-focusborder/images/download.svg) ](https://bintray.com/zhousuqiang/maven/tv-focusborder/_latestVersion) 2 | 3 | 群 号:484790001 [群二维码](https://github.com/zhousuqiang/TvRecyclerView/blob/master/images/qq.png) 4 | 5 | >* 支持[TvRecyclerView](https://github.com/zhousuqiang/TvRecyclerView)焦点移动; 6 | >* 支持颜色或图片作为焦点框; 7 | >* 支持焦点框圆角变化; 8 | 9 | ### 效果 10 | 11 | ![](https://github.com/zhousuqiang/TvFocusBorder/blob/master/image/focus3.gif) 12 | 13 | ### Gradle 引入 14 | ```java 15 | implementation 'com.owen:tv-focusborder:1.0.0' 16 | ``` 17 | 18 | ### 使用 19 | ```java 20 | /** 颜色焦点框 */ 21 | FocusBorder mColorFocusBorder = new FocusBorder.Builder().asColor() 22 | //阴影宽度(方法shadowWidth(18f)也可以设置阴影宽度) 23 | .shadowWidth(TypedValue.COMPLEX_UNIT_DIP, 20f) 24 | //阴影颜色 25 | .shadowColor(Color.parseColor("#3FBB66")) 26 | //边框宽度(方法borderWidth(2f)也可以设置边框宽度) 27 | .borderWidth(TypedValue.COMPLEX_UNIT_DIP, 3.2f) 28 | //边框颜色 29 | .borderColor(Color.parseColor("#00FF00")) 30 | //padding值 31 | .padding(2f) 32 | //动画时长 33 | .animDuration(300) 34 | //不要闪光动画 35 | .noShimmer() 36 | //闪光颜色 37 | .shimmerColor(Color.parseColor("#66FFFFFF")) 38 | //闪光动画时长 39 | .shimmerDuration(1000) 40 | .build(this); 41 | 42 | //焦点监听 方式一:绑定整个页面的焦点监听事件 43 | mColorFocusBorder.boundGlobalFocusListener(new FocusBorder.OnFocusCallback() { 44 | @Override 45 | public FocusBorder.Options onFocus(View oldFocus, View newFocus) { 46 | if(null != newFocus) { 47 | switch (newFocus.getId()) { 48 | case R.id.round_frame_layout_1: 49 | case R.id.round_frame_layout_6: 50 | float scale = 1.2f; 51 | return FocusBorder.OptionsFactory.get(scale, scale, dp2px(radius) * scale); 52 | 53 | default: 54 | break; 55 | } 56 | } 57 | //返回null表示不使用焦点框框架 58 | return null; 59 | } 60 | }); 61 | 62 | 63 | /** 图片焦点框 */ 64 | FocusBorder mDrawableFocusBorder = new FocusBorder.Builder().asDrawable() 65 | .borderDrawableRes(R.mipmap.focus) 66 | .build(this); 67 | 68 | //焦点监听 方式二:单个的焦点监听事件 69 | view.setOnFocusChangeListener(new View.OnFocusChangeListener() { 70 | @Override 71 | public void onFocusChange(View v, boolean hasFocus) { 72 | if(hasFocus) { 73 | mDrawableFocusBorder.onFocus(v, FocusBorder.OptionsFactory.get(1.2f, 1.2f)); 74 | } 75 | } 76 | }); 77 | 78 | ``` 79 | 80 | ### 更详细的使用请见exmaple 81 | 82 | ------ 83 | 84 | 85 | 作者 [owen](https://github.com/zhousuqiang) 86 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | android { 4 | compileSdkVersion rootProject.ext.compileSdkVersion 5 | defaultConfig { 6 | applicationId "com.owen.focus.example" 7 | minSdkVersion rootProject.ext.minSdkVersion 8 | targetSdkVersion rootProject.ext.targetSdkVersion 9 | versionCode 1 10 | versionName "1.0" 11 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 12 | } 13 | buildTypes { 14 | release { 15 | minifyEnabled false 16 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 17 | } 18 | } 19 | } 20 | 21 | dependencies { 22 | implementation fileTree(dir: 'libs', include: ['*.jar']) 23 | implementation 'com.android.support:appcompat-v7:'+ rootProject.ext.supportVersion 24 | implementation 'com.android.support.constraint:constraint-layout:1.1.3' 25 | testImplementation 'junit:junit:4.12' 26 | androidTestImplementation 'com.android.support.test:runner:1.0.2' 27 | androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' 28 | 29 | implementation 'me.jessyan:autosize:1.0.5' 30 | implementation project(":tv-focusborder") 31 | // implementation 'com.owen:tv-focusborder:1.0.0' 32 | } 33 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/owen/focus/example/ExampleInstrumentedTest.java: -------------------------------------------------------------------------------- 1 | package com.owen.focus.example; 2 | 3 | import android.content.Context; 4 | import android.support.test.InstrumentationRegistry; 5 | import android.support.test.runner.AndroidJUnit4; 6 | 7 | import org.junit.Test; 8 | import org.junit.runner.RunWith; 9 | 10 | import static org.junit.Assert.*; 11 | 12 | /** 13 | * Instrumented test, which will execute on an Android device. 14 | * 15 | * @see Testing documentation 16 | */ 17 | @RunWith(AndroidJUnit4.class) 18 | public class ExampleInstrumentedTest { 19 | @Test 20 | public void useAppContext() { 21 | // Context of the app under test. 22 | Context appContext = InstrumentationRegistry.getTargetContext(); 23 | 24 | assertEquals("com.owen.focus.example", appContext.getPackageName()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 12 | 13 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/owen/focus/example/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.owen.focus.example; 2 | 3 | import android.graphics.Color; 4 | import android.support.v7.app.AppCompatActivity; 5 | import android.os.Bundle; 6 | import android.util.TypedValue; 7 | import android.view.View; 8 | import android.view.ViewGroup; 9 | 10 | import com.owen.focus.FocusBorder; 11 | 12 | /** 13 | * @author ZhouSuQiang 14 | */ 15 | public class MainActivity extends AppCompatActivity implements View.OnFocusChangeListener { 16 | /** 颜色焦点框 */ 17 | private FocusBorder mColorFocusBorder; 18 | /** 图片焦点框 */ 19 | private FocusBorder mDrawableFocusBorder; 20 | 21 | @Override 22 | protected void onCreate(Bundle savedInstanceState) { 23 | super.onCreate(savedInstanceState); 24 | setContentView(R.layout.activity_main); 25 | 26 | initBorder(); 27 | } 28 | 29 | private void initBorder() { 30 | /** 颜色焦点框 */ 31 | mColorFocusBorder = new FocusBorder.Builder().asColor() 32 | //阴影宽度(方法shadowWidth(18f)也可以设置阴影宽度) 33 | .shadowWidth(TypedValue.COMPLEX_UNIT_DIP, 20f) 34 | //阴影颜色 35 | .shadowColor(Color.parseColor("#3FBB66")) 36 | //边框宽度(方法borderWidth(2f)也可以设置边框宽度) 37 | .borderWidth(TypedValue.COMPLEX_UNIT_DIP, 3.2f) 38 | //边框颜色 39 | .borderColor(Color.parseColor("#00FF00")) 40 | //padding值 41 | .padding(2f) 42 | //动画时长 43 | .animDuration(300) 44 | //不要闪光动画 45 | // .noShimmer() 46 | //闪光颜色 47 | .shimmerColor(Color.parseColor("#66FFFFFF")) 48 | //闪光动画时长 49 | .shimmerDuration(1000) 50 | .build(this); 51 | 52 | //焦点监听 方式一:绑定整个页面的焦点监听事件 53 | mColorFocusBorder.boundGlobalFocusListener(new FocusBorder.OnFocusCallback() { 54 | @Override 55 | public FocusBorder.Options onFocus(View oldFocus, View newFocus) { 56 | if(null != newFocus) { 57 | switch (newFocus.getId()) { 58 | case R.id.round_frame_layout_1: 59 | case R.id.round_frame_layout_6: 60 | return createColorBorderOptions(0); 61 | case R.id.round_frame_layout_2: 62 | case R.id.round_frame_layout_7: 63 | return createColorBorderOptions(20); 64 | case R.id.round_frame_layout_3: 65 | case R.id.round_frame_layout_8: 66 | return createColorBorderOptions(40); 67 | case R.id.round_frame_layout_4: 68 | case R.id.round_frame_layout_9: 69 | return createColorBorderOptions(60); 70 | case R.id.round_frame_layout_5: 71 | case R.id.round_frame_layout_10: 72 | return createColorBorderOptions(90); 73 | 74 | default: 75 | break; 76 | } 77 | } 78 | mColorFocusBorder.setVisible(false); 79 | //返回null表示不使用焦点框框架 80 | return null; 81 | } 82 | }); 83 | 84 | 85 | /** 图片焦点框 */ 86 | mDrawableFocusBorder = new FocusBorder.Builder().asDrawable() 87 | .borderDrawableRes(R.mipmap.focus) 88 | .build(this); 89 | 90 | //焦点监听 方式一:绑定整个页面的焦点监听事件 91 | ViewGroup layout2 = findViewById(R.id.layout_2); 92 | for (int i = 0; i < layout2.getChildCount(); i++) { 93 | View view = layout2.getChildAt(i); 94 | if(null != view) { 95 | view.setOnFocusChangeListener(this); 96 | } 97 | } 98 | } 99 | 100 | private FocusBorder.Options createColorBorderOptions(int radius) { 101 | mDrawableFocusBorder.setVisible(false); 102 | float scale = 1.2f; 103 | return FocusBorder.OptionsFactory.get(scale, scale, dp2px(radius) * scale); 104 | } 105 | 106 | @Override 107 | public void onFocusChange(View v, boolean hasFocus) { 108 | if(hasFocus) { 109 | mDrawableFocusBorder.onFocus(v, FocusBorder.OptionsFactory.get(1.2f, 1.2f)); 110 | } 111 | } 112 | 113 | private float dp2px(int dp) { 114 | return TypedValue.applyDimension( 115 | TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics()); 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /app/src/main/java/com/owen/focus/example/view/RoundFrameLayout.java: -------------------------------------------------------------------------------- 1 | package com.owen.focus.example.view; 2 | 3 | import android.content.Context; 4 | import android.content.res.TypedArray; 5 | import android.graphics.Canvas; 6 | import android.graphics.Paint; 7 | import android.graphics.Path; 8 | import android.graphics.PorterDuff; 9 | import android.graphics.PorterDuffXfermode; 10 | import android.graphics.RectF; 11 | import android.os.Build; 12 | import android.util.AttributeSet; 13 | import android.widget.FrameLayout; 14 | 15 | import com.owen.focus.example.R; 16 | 17 | import static android.graphics.Canvas.ALL_SAVE_FLAG; 18 | 19 | /** 20 | * 21 | * @author owen 22 | * @date 2016/10/20 23 | * 24 | */ 25 | 26 | public class RoundFrameLayout extends FrameLayout { 27 | 28 | private float mTopLeftRadius; 29 | private float mTopRightRadius; 30 | private float mBottomLeftRadius; 31 | private float mBottomRightRadius; 32 | 33 | private boolean mIsDrawRound; 34 | private Path mRoundPath; 35 | private Paint mRoundPaint; 36 | private RectF mRoundRectF; 37 | 38 | private boolean mIsDrawn; 39 | 40 | public RoundFrameLayout(Context context) { 41 | this(context, null); 42 | } 43 | 44 | public RoundFrameLayout(Context context, AttributeSet attrs) { 45 | this(context, attrs, 0); 46 | } 47 | 48 | public RoundFrameLayout(Context context, AttributeSet attrs, int defStyle) { 49 | super(context, attrs, defStyle); 50 | if (attrs != null) { 51 | TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.RoundFrameLayout); 52 | float radius = ta.getDimension(R.styleable.RoundFrameLayout_rfl_radius, 0); 53 | mTopLeftRadius = ta.getDimension(R.styleable.RoundFrameLayout_topLeftRadius, radius); 54 | mTopRightRadius = ta.getDimension(R.styleable.RoundFrameLayout_topRightRadius, radius); 55 | mBottomLeftRadius = ta.getDimension(R.styleable.RoundFrameLayout_bottomLeftRadius, radius); 56 | mBottomRightRadius = ta.getDimension(R.styleable.RoundFrameLayout_bottomRightRadius, radius); 57 | ta.recycle(); 58 | } 59 | mIsDrawRound = mTopLeftRadius != 0 || mTopRightRadius != 0 || mBottomLeftRadius != 0 || mBottomRightRadius != 0; 60 | 61 | mRoundPaint = new Paint(); 62 | mRoundPaint.setAntiAlias(true); 63 | // 取两层绘制交集。显示下层。 64 | mRoundPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN)); 65 | 66 | } 67 | 68 | @Override 69 | public boolean isInEditMode() { 70 | return true; 71 | } 72 | 73 | @Override 74 | protected void onSizeChanged(int w, int h, int oldw, int oldh) { 75 | super.onSizeChanged(w, h, oldw, oldh); 76 | if((w != oldw || h != oldh) && mIsDrawRound) { 77 | final Path path = new Path(); 78 | mRoundRectF = new RectF(0, 0, w, h); 79 | final float[] radii = new float[]{ 80 | mTopLeftRadius, mTopLeftRadius, 81 | mTopRightRadius, mTopRightRadius, 82 | mBottomRightRadius, mBottomRightRadius, 83 | mBottomLeftRadius, mBottomLeftRadius}; 84 | path.addRoundRect(mRoundRectF, radii, Path.Direction.CW); 85 | mRoundPath = path; 86 | } 87 | } 88 | 89 | @Override 90 | public void draw(Canvas canvas) { 91 | if(!mIsDrawRound) { 92 | super.draw(canvas); 93 | } else { 94 | mIsDrawn = true; 95 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 96 | canvas.saveLayer(mRoundRectF, null); 97 | } else { 98 | canvas.saveLayer(mRoundRectF, null, ALL_SAVE_FLAG); 99 | } 100 | super.draw(canvas); 101 | canvas.drawPath(mRoundPath, mRoundPaint); 102 | canvas.restore(); 103 | } 104 | } 105 | 106 | @Override 107 | protected void dispatchDraw(Canvas canvas) { 108 | if(mIsDrawn || !mIsDrawRound) { 109 | super.dispatchDraw(canvas); 110 | } else { 111 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 112 | canvas.saveLayer(mRoundRectF, null); 113 | } else { 114 | canvas.saveLayer(mRoundRectF, null, ALL_SAVE_FLAG); 115 | } 116 | super.dispatchDraw(canvas); 117 | canvas.drawPath(mRoundPath, mRoundPaint); 118 | canvas.restore(); 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /app/src/main/java/com/owen/focus/example/view/TvHorizontalScrollView.java: -------------------------------------------------------------------------------- 1 | package com.owen.focus.example.view; 2 | 3 | import android.content.Context; 4 | import android.content.res.TypedArray; 5 | import android.graphics.Rect; 6 | import android.util.AttributeSet; 7 | import android.util.Log; 8 | import android.view.animation.AccelerateInterpolator; 9 | import android.view.animation.Interpolator; 10 | import android.widget.HorizontalScrollView; 11 | import android.widget.OverScroller; 12 | 13 | import com.owen.focus.example.R; 14 | 15 | import java.lang.reflect.Field; 16 | 17 | /** 18 | * 19 | * @author owen 20 | * @date 2016/10/21 21 | */ 22 | 23 | public class TvHorizontalScrollView extends HorizontalScrollView { 24 | private FixedSpeedScroller mScroller; 25 | private float mSelectedItemOffsetStart = 0; 26 | private float mSelectedItemOffsetEnd = 0; 27 | private boolean mIsSelectedCentered = false; 28 | 29 | public TvHorizontalScrollView(Context context) { 30 | this(context, null); 31 | } 32 | 33 | public TvHorizontalScrollView(Context context, AttributeSet attrs) { 34 | this(context, attrs, 0); 35 | } 36 | 37 | public TvHorizontalScrollView(Context context, AttributeSet attrs, int defStyleAttr) { 38 | super(context, attrs, defStyleAttr); 39 | initScroller(getContext()); 40 | 41 | if(null != attrs) { 42 | TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TvHorizontalScrollView, defStyleAttr, 0); 43 | if(null != a) { 44 | mSelectedItemOffsetStart = a.getDimension(R.styleable.TvHorizontalScrollView_tsv_selected_offset_start, 0); 45 | mSelectedItemOffsetEnd = a.getDimension(R.styleable.TvHorizontalScrollView_tsv_selected_offset_end, 0); 46 | mIsSelectedCentered = a.getBoolean(R.styleable.TvHorizontalScrollView_tsv_is_selected_centered, false); 47 | setScrollerDuration(a.getInt(R.styleable.TvHorizontalScrollView_tsv_scroller_duration, 600)); 48 | } 49 | } 50 | 51 | } 52 | 53 | private void initScroller(Context context) { 54 | if(null != mScroller) 55 | return; 56 | try { 57 | mScroller = new FixedSpeedScroller(context, new AccelerateInterpolator()); 58 | Field field = this.getClass().getDeclaredField("mScroller"); 59 | field.setAccessible(true); 60 | field.set(this, mScroller); 61 | } catch (Exception e) { 62 | Log.e(this.getClass().getSimpleName(), e.getMessage()); 63 | } 64 | } 65 | 66 | /** 67 | * 设置滚动时长 68 | * @param duration 69 | */ 70 | public void setScrollerDuration(int duration){ 71 | initScroller(getContext()); 72 | if(null != mScroller) { 73 | mScroller.setScrollDuration(duration); 74 | } 75 | } 76 | 77 | /** 78 | * 获取滚动时长 79 | * @return 80 | */ 81 | public int getScrollerDuration() { 82 | initScroller(getContext()); 83 | if(null != mScroller) { 84 | return mScroller.getScrollDuration(); 85 | } 86 | return 0; 87 | } 88 | 89 | public void setSelectedCentered(boolean selectedCentered) { 90 | mIsSelectedCentered = selectedCentered; 91 | } 92 | 93 | public boolean isSelectedCentered() { 94 | return mIsSelectedCentered; 95 | } 96 | 97 | public void setSelectedItemOffsetStart(float selectedItemOffsetStart) { 98 | mSelectedItemOffsetStart = selectedItemOffsetStart; 99 | } 100 | 101 | public float getSelectedItemOffsetStart() { 102 | return mSelectedItemOffsetStart; 103 | } 104 | 105 | public void setSelectedItemOffsetEnd(float selectedItemOffsetEnd) { 106 | mSelectedItemOffsetEnd = selectedItemOffsetEnd; 107 | } 108 | 109 | public float getSelectedItemOffsetEnd() { 110 | return mSelectedItemOffsetEnd; 111 | } 112 | 113 | //此处借鉴:https://github.com/FrozenFreeFall/Android-tv-widget 114 | @Override 115 | protected int computeScrollDeltaToGetChildRectOnScreen(Rect rect) { 116 | if (getChildCount() == 0) 117 | return 0; 118 | 119 | int width = getWidth(); 120 | int screenLeft = getScrollX(); 121 | int screenRight = screenLeft + width; 122 | 123 | if(mIsSelectedCentered && null != rect && !rect.isEmpty()) { 124 | mSelectedItemOffsetStart = width - getPaddingLeft() - getPaddingRight() - rect.width(); 125 | mSelectedItemOffsetStart /= 2f; 126 | mSelectedItemOffsetEnd = mSelectedItemOffsetStart; 127 | } 128 | 129 | if (rect.left > 0) { 130 | screenLeft += mSelectedItemOffsetStart; 131 | } 132 | if (rect.right < getChildAt(0).getWidth()) { 133 | screenRight -= mSelectedItemOffsetEnd; 134 | } 135 | 136 | int scrollXDelta = 0; 137 | if (rect.right > screenRight && rect.left > screenLeft) { 138 | if (rect.width() > width) { 139 | scrollXDelta += (rect.left - screenLeft); 140 | } else { 141 | scrollXDelta += (rect.right - screenRight); 142 | } 143 | int right = getChildAt(0).getRight(); 144 | int distanceToRight = right - screenRight; 145 | scrollXDelta = Math.min(scrollXDelta, distanceToRight); 146 | 147 | } else if (rect.left < screenLeft && rect.right < screenRight) { 148 | if (rect.width() > width) { 149 | scrollXDelta -= (screenRight - rect.right); 150 | } else { 151 | scrollXDelta -= (screenLeft - rect.left); 152 | } 153 | scrollXDelta = Math.max(scrollXDelta, -getScrollX()); 154 | } 155 | return scrollXDelta; 156 | } 157 | 158 | /** 159 | * 固定速度的Scroller 160 | * */ 161 | public static class FixedSpeedScroller extends OverScroller { 162 | private int mDuration = 600; 163 | 164 | public FixedSpeedScroller(Context context) { 165 | super(context); 166 | } 167 | 168 | public FixedSpeedScroller(Context context, Interpolator interpolator) { 169 | super(context, interpolator); 170 | } 171 | 172 | @Override 173 | public void startScroll(int startX, int startY, int dx, int dy, int duration) { 174 | // Ignore received duration, use fixed one instead 175 | super.startScroll(startX, startY, dx, dy, mDuration); 176 | } 177 | 178 | @Override 179 | public void startScroll(int startX, int startY, int dx, int dy) { 180 | // Ignore received duration, use fixed one instead 181 | super.startScroll(startX, startY, dx, dy, mDuration); 182 | } 183 | 184 | public void setScrollDuration(int time) { 185 | mDuration = time; 186 | } 187 | 188 | public int getScrollDuration() { 189 | return mDuration; 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | 16 | 21 | 26 | 31 | 36 | 41 | 46 | 51 | 56 | 61 | 66 | 71 | 76 | 81 | 86 | 91 | 96 | 101 | 106 | 111 | 116 | 121 | 126 | 131 | 136 | 141 | 146 | 151 | 156 | 161 | 166 | 171 | 172 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 18 | 19 | 27 | 28 | 35 | 36 | 44 | 45 | 53 | 54 | 62 | 63 | 71 | 72 | 80 | 81 | 89 | 90 | 98 | 99 | 107 | 108 | 116 | 117 | 118 | 119 | 120 | 121 | 130 | 131 | 139 | 140 | 145 | 146 | 152 | 153 | 159 | 160 | 166 | 167 | 173 | 174 | 180 | 181 | 187 | 188 | 194 | 195 | 201 | 202 | 203 | 204 | 205 | 206 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pj567/TvFocusBorder/d2c8bde027a6cf1e2df82438de823d3dd2e07c17/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pj567/TvFocusBorder/d2c8bde027a6cf1e2df82438de823d3dd2e07c17/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/focus.9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pj567/TvFocusBorder/d2c8bde027a6cf1e2df82438de823d3dd2e07c17/app/src/main/res/mipmap-mdpi/focus.9.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pj567/TvFocusBorder/d2c8bde027a6cf1e2df82438de823d3dd2e07c17/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pj567/TvFocusBorder/d2c8bde027a6cf1e2df82438de823d3dd2e07c17/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pj567/TvFocusBorder/d2c8bde027a6cf1e2df82438de823d3dd2e07c17/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pj567/TvFocusBorder/d2c8bde027a6cf1e2df82438de823d3dd2e07c17/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pj567/TvFocusBorder/d2c8bde027a6cf1e2df82438de823d3dd2e07c17/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pj567/TvFocusBorder/d2c8bde027a6cf1e2df82438de823d3dd2e07c17/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pj567/TvFocusBorder/d2c8bde027a6cf1e2df82438de823d3dd2e07c17/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pj567/TvFocusBorder/d2c8bde027a6cf1e2df82438de823d3dd2e07c17/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3F51B5 4 | #303F9F 5 | #FF4081 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/round_framelayout_attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/scrollview_values.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | TvFocusBorder 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/test/java/com/owen/focus/example/ExampleUnitTest.java: -------------------------------------------------------------------------------- 1 | package com.owen.focus.example; 2 | 3 | import org.junit.Test; 4 | 5 | import static org.junit.Assert.*; 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * @see Testing documentation 11 | */ 12 | public class ExampleUnitTest { 13 | @Test 14 | public void addition_isCorrect() { 15 | assertEquals(4, 2 + 2); 16 | } 17 | } -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | 5 | repositories { 6 | google() 7 | jcenter() 8 | } 9 | dependencies { 10 | classpath 'com.android.tools.build:gradle:3.1.4' 11 | 12 | classpath 'com.novoda:bintray-release:0.9' 13 | } 14 | } 15 | 16 | // 指定javadoc UTF-8格式(bintray-release) 17 | task javadoc(type: Javadoc) { 18 | options.encoding = "utf-8" 19 | } 20 | 21 | allprojects { 22 | repositories { 23 | google() 24 | jcenter() 25 | jcenter { url "http://jcenter.bintray.com/" } 26 | } 27 | } 28 | 29 | task clean(type: Delete) { 30 | delete rootProject.buildDir 31 | } 32 | 33 | ext { 34 | compileSdkVersion = 27 35 | minSdkVersion = 14 36 | targetSdkVersion = 25 37 | supportVersion = "27.1.1" 38 | } 39 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /image/focus3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pj567/TvFocusBorder/d2c8bde027a6cf1e2df82438de823d3dd2e07c17/image/focus3.gif -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app', ":tv-focusborder" 2 | -------------------------------------------------------------------------------- /tv-focusborder/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /tv-focusborder/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'com.novoda.bintray-release' 3 | 4 | android { 5 | compileSdkVersion rootProject.ext.compileSdkVersion 6 | 7 | defaultConfig { 8 | minSdkVersion rootProject.ext.minSdkVersion 9 | targetSdkVersion rootProject.ext.targetSdkVersion 10 | versionCode 1 11 | versionName "1.0" 12 | 13 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 14 | 15 | } 16 | 17 | lintOptions { 18 | abortOnError false 19 | } 20 | 21 | buildTypes { 22 | release { 23 | minifyEnabled false 24 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 25 | } 26 | } 27 | } 28 | 29 | publish { 30 | userOrg = 'zhousuqiang' 31 | groupId = 'com.owen' 32 | artifactId = 'tv-focusborder' 33 | publishVersion = '1.0.0' 34 | desc = 'TvFocusBorder is android tv focus border' 35 | website = 'https://github.com/zhousuqiang/TvFocusBorder' 36 | } 37 | 38 | dependencies { 39 | implementation "com.android.support:appcompat-v7:$rootProject.ext.supportVersion" 40 | api "com.android.support:recyclerview-v7:$rootProject.ext.supportVersion" 41 | } 42 | -------------------------------------------------------------------------------- /tv-focusborder/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /tv-focusborder/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /tv-focusborder/src/main/java/com/owen/focus/AbsFocusBorder.java: -------------------------------------------------------------------------------- 1 | package com.owen.focus; 2 | 3 | import android.animation.Animator; 4 | import android.animation.AnimatorListenerAdapter; 5 | import android.animation.AnimatorSet; 6 | import android.animation.ObjectAnimator; 7 | import android.app.Activity; 8 | import android.content.Context; 9 | import android.graphics.Canvas; 10 | import android.graphics.LinearGradient; 11 | import android.graphics.Matrix; 12 | import android.graphics.Paint; 13 | import android.graphics.Point; 14 | import android.graphics.Rect; 15 | import android.graphics.RectF; 16 | import android.graphics.Shader; 17 | import android.os.Build; 18 | import android.support.annotation.ColorInt; 19 | import android.support.annotation.ColorRes; 20 | import android.support.annotation.NonNull; 21 | import android.support.annotation.Nullable; 22 | import android.support.v4.view.ViewCompat; 23 | import android.support.v7.widget.RecyclerView; 24 | import android.util.Log; 25 | import android.view.View; 26 | import android.view.ViewGroup; 27 | import android.view.ViewParent; 28 | import android.view.ViewTreeObserver; 29 | import android.view.animation.DecelerateInterpolator; 30 | import android.view.animation.LinearInterpolator; 31 | 32 | import java.lang.ref.WeakReference; 33 | import java.util.ArrayList; 34 | import java.util.List; 35 | 36 | /** 37 | * Created by owen on 2017/7/20. 38 | */ 39 | 40 | public abstract class AbsFocusBorder extends View implements FocusBorder, ViewTreeObserver.OnGlobalFocusChangeListener{ 41 | private static final long DEFAULT_ANIM_DURATION_TIME = 300; 42 | private static final long DEFAULT_SHIMMER_DURATION_TIME = 1000; 43 | 44 | protected long mAnimDuration = DEFAULT_ANIM_DURATION_TIME; 45 | protected long mShimmerDuration = DEFAULT_SHIMMER_DURATION_TIME; 46 | protected RectF mFrameRectF = new RectF(); 47 | protected RectF mPaddingRectF = new RectF(); 48 | protected RectF mPaddingOfsetRectF = new RectF(); 49 | protected RectF mTempRectF = new RectF(); 50 | 51 | private LinearGradient mShimmerLinearGradient; 52 | private Matrix mShimmerGradientMatrix; 53 | private Paint mShimmerPaint; 54 | private int mShimmerColor = 0x66FFFFFF; 55 | private float mShimmerTranslate = 0; 56 | private boolean mShimmerAnimating = false; 57 | private boolean mIsShimmerAnim = true; 58 | private boolean mReAnim = false; //修复RecyclerView焦点临时标记 59 | 60 | private ObjectAnimator mTranslationXAnimator; 61 | private ObjectAnimator mTranslationYAnimator; 62 | private ObjectAnimator mWidthAnimator; 63 | private ObjectAnimator mHeightAnimator; 64 | private ObjectAnimator mShimmerAnimator; 65 | private AnimatorSet mAnimatorSet; 66 | 67 | private RecyclerViewScrollListener mRecyclerViewScrollListener; 68 | private WeakReference mWeakRecyclerView; 69 | private WeakReference mOldFocusView; 70 | private OnFocusCallback mOnFocusCallback; 71 | private boolean mIsVisible = false; 72 | 73 | private float mScaleX; 74 | private float mScaleY; 75 | 76 | protected AbsFocusBorder(Context context, int shimmerColor, long shimmerDuration, boolean isShimmerAnim, long animDuration, RectF paddingOfsetRectF) { 77 | super(context); 78 | 79 | this.mShimmerColor = shimmerColor; 80 | this.mShimmerDuration = shimmerDuration; 81 | this.mIsShimmerAnim = isShimmerAnim; 82 | this.mAnimDuration = animDuration; 83 | if(null != paddingOfsetRectF) { 84 | this.mPaddingOfsetRectF.set(paddingOfsetRectF); 85 | } 86 | //关闭硬件加速 87 | setLayerType(View.LAYER_TYPE_SOFTWARE, null); 88 | setVisibility(VISIBLE); 89 | setAlpha(0f); 90 | 91 | mShimmerPaint = new Paint(); 92 | mShimmerGradientMatrix = new Matrix(); 93 | } 94 | 95 | @Override 96 | public boolean isInEditMode() { 97 | return true; 98 | } 99 | 100 | /** 101 | * 绘制闪光 102 | * @param canvas 103 | */ 104 | protected void onDrawShimmer(Canvas canvas) { 105 | if (mShimmerAnimating) { 106 | canvas.save(); 107 | mTempRectF.set(mFrameRectF); 108 | mTempRectF.intersect(mPaddingOfsetRectF); 109 | float shimmerTranslateX = mTempRectF.width() * mShimmerTranslate; 110 | float shimmerTranslateY = mTempRectF.height() * mShimmerTranslate; 111 | mShimmerGradientMatrix.setTranslate(shimmerTranslateX, shimmerTranslateY); 112 | mShimmerLinearGradient.setLocalMatrix(mShimmerGradientMatrix); 113 | canvas.drawRoundRect(mTempRectF, getRoundRadius(), getRoundRadius(), mShimmerPaint); 114 | canvas.restore(); 115 | } 116 | } 117 | 118 | @Override 119 | protected void onSizeChanged(int w, int h, int oldw, int oldh) { 120 | super.onSizeChanged(w, h, oldw, oldh); 121 | if(w != oldw || h != oldh) { 122 | mFrameRectF.set(mPaddingRectF.left, mPaddingRectF.top, w - mPaddingRectF.right, h - mPaddingRectF.bottom); 123 | } 124 | } 125 | 126 | @Override 127 | protected void onDraw(Canvas canvas) { 128 | super.onDraw(canvas); 129 | onDrawShimmer(canvas); 130 | } 131 | 132 | @Override 133 | protected void onDetachedFromWindow() { 134 | unBoundGlobalFocusListener(); 135 | super.onDetachedFromWindow(); 136 | } 137 | 138 | private void setShimmerAnimating(boolean shimmerAnimating) { 139 | mShimmerAnimating = shimmerAnimating; 140 | if(mShimmerAnimating) { 141 | mTempRectF.set(mFrameRectF); 142 | mTempRectF.left += mPaddingOfsetRectF.left; 143 | mTempRectF.top += mPaddingOfsetRectF.top; 144 | mTempRectF.right -= mPaddingOfsetRectF.right; 145 | mTempRectF.bottom -= mPaddingOfsetRectF.bottom; 146 | mShimmerLinearGradient = new LinearGradient( 147 | 0, 0, mTempRectF.width(), mTempRectF.height(), 148 | new int[]{0x00FFFFFF, 0x1AFFFFFF, mShimmerColor, 0x1AFFFFFF, 0x00FFFFFF}, 149 | new float[]{0f, 0.2f, 0.5f, 0.8f, 1f}, Shader.TileMode.CLAMP); 150 | mShimmerPaint.setShader(mShimmerLinearGradient); 151 | } 152 | } 153 | 154 | protected void setShimmerTranslate(float shimmerTranslate) { 155 | if(mIsShimmerAnim && mShimmerTranslate != shimmerTranslate) { 156 | mShimmerTranslate = shimmerTranslate; 157 | ViewCompat.postInvalidateOnAnimation(this); 158 | } 159 | } 160 | 161 | protected float getShimmerTranslate() { 162 | return mShimmerTranslate; 163 | } 164 | 165 | protected void setWidth(int width) { 166 | if(getLayoutParams().width != width) { 167 | getLayoutParams().width = width; 168 | requestLayout(); 169 | } 170 | } 171 | 172 | protected void setHeight(int height) { 173 | if(getLayoutParams().height != height) { 174 | getLayoutParams().height = height; 175 | requestLayout(); 176 | } 177 | } 178 | 179 | @Override 180 | public void setVisible(boolean visible) { 181 | if(mIsVisible != visible) { 182 | mIsVisible = visible; 183 | 184 | animate().alpha(visible ? 1f : 0f).setDuration(mAnimDuration).start(); 185 | 186 | if(!visible && null != mOldFocusView && null != mOldFocusView.get()) { 187 | runFocusScaleAnimation(mOldFocusView.get(), 1f, 1f); 188 | mOldFocusView.clear(); 189 | mOldFocusView = null; 190 | } 191 | } 192 | } 193 | 194 | @Override 195 | public boolean isVisible() { 196 | return mIsVisible; 197 | } 198 | 199 | private void registerScrollListener(RecyclerView recyclerView) { 200 | if(null != mWeakRecyclerView && mWeakRecyclerView.get() == recyclerView) { 201 | return; 202 | } 203 | 204 | if(null == mRecyclerViewScrollListener) { 205 | mRecyclerViewScrollListener = new RecyclerViewScrollListener(this); 206 | } 207 | 208 | if(null != mWeakRecyclerView && null != mWeakRecyclerView.get()) { 209 | mWeakRecyclerView.get().removeOnScrollListener(mRecyclerViewScrollListener); 210 | mWeakRecyclerView.clear(); 211 | } 212 | 213 | recyclerView.removeOnScrollListener(mRecyclerViewScrollListener); 214 | recyclerView.addOnScrollListener(mRecyclerViewScrollListener); 215 | mWeakRecyclerView = new WeakReference<>(recyclerView); 216 | } 217 | 218 | protected Rect findLocationWithView(View view) { 219 | return findOffsetDescendantRectToMyCoords(view); 220 | } 221 | 222 | protected Rect findOffsetDescendantRectToMyCoords(View descendant) { 223 | final ViewGroup root = (ViewGroup) getParent(); 224 | final Rect rect = new Rect(); 225 | mReAnim = false; 226 | if (descendant == root) { 227 | return rect; 228 | } 229 | 230 | final View srcDescendant = descendant; 231 | 232 | ViewParent theParent = descendant.getParent(); 233 | Object tag; 234 | Point point; 235 | 236 | // search and offset up to the parent 237 | while ((theParent != null) 238 | && (theParent instanceof View) 239 | && (theParent != root)) { 240 | 241 | rect.offset(descendant.getLeft() - descendant.getScrollX(), 242 | descendant.getTop() - descendant.getScrollY()); 243 | 244 | //兼容TvRecyclerView 245 | if (theParent instanceof RecyclerView) { 246 | final RecyclerView rv = (RecyclerView)theParent; 247 | registerScrollListener(rv); 248 | tag = rv.getTag(); 249 | if (null != tag && tag instanceof Point) { 250 | point = (Point) tag; 251 | rect.offset(-point.x, -point.y); 252 | } 253 | if(null == tag && rv.getScrollState() != RecyclerView.SCROLL_STATE_IDLE 254 | && (mRecyclerViewScrollListener.mScrolledX != 0 || mRecyclerViewScrollListener.mScrolledY != 0)) { 255 | mReAnim = true; 256 | } 257 | } 258 | 259 | descendant = (View) theParent; 260 | theParent = descendant.getParent(); 261 | } 262 | 263 | // now that we are up to this view, need to offset one more time 264 | // to get into our coordinate space 265 | if (theParent == root) { 266 | rect.offset(descendant.getLeft() - descendant.getScrollX(), 267 | descendant.getTop() - descendant.getScrollY()); 268 | } 269 | 270 | rect.right = rect.left + srcDescendant.getMeasuredWidth(); 271 | rect.bottom = rect.top + srcDescendant.getMeasuredHeight(); 272 | 273 | return rect; 274 | } 275 | 276 | @Override 277 | public void onFocus(@NonNull View focusView, FocusBorder.Options options) { 278 | View oldFocus = null != mOldFocusView ? mOldFocusView.get() : null; 279 | if(null != oldFocus) { 280 | runFocusScaleAnimation(oldFocus, 1f, 1f); 281 | mOldFocusView.clear(); 282 | } 283 | 284 | if(options instanceof Options) { 285 | restoreFocusBorder(oldFocus, focusView, (Options) options); 286 | setVisible(true); 287 | runFocusAnimation(focusView, (Options) options); 288 | mOldFocusView = new WeakReference<>(focusView); 289 | } 290 | } 291 | 292 | private void restoreFocusBorder(View oldFocus, View newFocus, Options options) { 293 | if(null == oldFocus) { 294 | final float paddingWidth = mPaddingRectF.left + mPaddingRectF.right + mPaddingOfsetRectF.left + mPaddingOfsetRectF.right; 295 | final float paddingHeight = mPaddingRectF.top + mPaddingRectF.bottom + mPaddingOfsetRectF.top + mPaddingOfsetRectF.bottom; 296 | final Rect toRect = findLocationWithView(newFocus); 297 | toRect.inset((int)(-paddingWidth/2), (int)(-paddingHeight/2)); 298 | setWidth(toRect.width()); 299 | setHeight(toRect.height()); 300 | setTranslationX(toRect.left); 301 | setTranslationY(toRect.top); 302 | } 303 | } 304 | 305 | @Override 306 | public void boundGlobalFocusListener(@NonNull OnFocusCallback callback) { 307 | mOnFocusCallback = callback; 308 | getViewTreeObserver().addOnGlobalFocusChangeListener(this); 309 | } 310 | 311 | @Override 312 | public void unBoundGlobalFocusListener() { 313 | if(null != mOnFocusCallback) { 314 | mOnFocusCallback = null; 315 | getViewTreeObserver().removeOnGlobalFocusChangeListener(this); 316 | } 317 | } 318 | 319 | @Override 320 | public void onGlobalFocusChanged(View oldFocus, View newFocus) { 321 | final Options options = null != mOnFocusCallback ? (Options) mOnFocusCallback.onFocus(oldFocus, newFocus) : null; 322 | if(null != options) { 323 | onFocus(newFocus, options); 324 | } 325 | } 326 | 327 | private void runFocusAnimation(View focusView, Options options) { 328 | mScaleX = options.scaleX; 329 | mScaleY = options.scaleY; 330 | // 焦点缩放动画 331 | runFocusScaleAnimation(focusView, mScaleX, mScaleY); 332 | // 移动边框的动画 333 | runBorderAnimation(focusView, options); 334 | } 335 | 336 | protected void runBorderAnimation(View focusView, Options options) { 337 | if(null == focusView) { 338 | return; 339 | } 340 | if(null != mAnimatorSet) { 341 | mAnimatorSet.cancel(); 342 | } 343 | 344 | createBorderAnimation(focusView, options); 345 | 346 | mAnimatorSet.start(); 347 | } 348 | 349 | /** 350 | * 焦点VIEW缩放动画 351 | * @param oldOrNewFocusView 352 | * @param 353 | */ 354 | protected void runFocusScaleAnimation(@Nullable final View oldOrNewFocusView, final float scaleX, final float scaleY) { 355 | if(null == oldOrNewFocusView) { 356 | return; 357 | } 358 | oldOrNewFocusView.animate().scaleX(scaleX).scaleY(scaleY).setDuration(mAnimDuration).start(); 359 | } 360 | 361 | protected void createBorderAnimation(View focusView, Options options) { 362 | 363 | final float paddingWidth = mPaddingRectF.left + mPaddingRectF.right + mPaddingOfsetRectF.left + mPaddingOfsetRectF.right; 364 | final float paddingHeight = mPaddingRectF.top + mPaddingRectF.bottom + mPaddingOfsetRectF.top + mPaddingOfsetRectF.bottom; 365 | final int offsetWidth = (int) (focusView.getMeasuredWidth() * (options.scaleX - 1f) + paddingWidth); 366 | final int offsetHeight = (int) (focusView.getMeasuredHeight() * (options.scaleY - 1f) + paddingHeight); 367 | 368 | final Rect fromRect = findLocationWithView(this); 369 | final Rect toRect = findLocationWithView(focusView); 370 | toRect.inset(-offsetWidth/2, -offsetHeight/2); 371 | 372 | final int newWidth = toRect.width(); 373 | final int newHeight = toRect.height(); 374 | final int newX = toRect.left - fromRect.left; 375 | final int newY = toRect.top - fromRect.top; 376 | 377 | final List together = new ArrayList<>(); 378 | final List appendTogether = getTogetherAnimators(newX, newY, newWidth, newHeight, options); 379 | together.add(getTranslationXAnimator(newX)); 380 | together.add(getTranslationYAnimator(newY)); 381 | together.add(getWidthAnimator(newWidth)); 382 | together.add(getHeightAnimator(newHeight)); 383 | 384 | if(null != appendTogether && !appendTogether.isEmpty()) { 385 | together.addAll(appendTogether); 386 | } 387 | 388 | final List sequentially = new ArrayList<>(); 389 | final List appendSequentially = getSequentiallyAnimators(newX, newY, newWidth, newHeight, options); 390 | if(mIsShimmerAnim) { 391 | sequentially.add(getShimmerAnimator()); 392 | } 393 | if(null != appendSequentially && !appendSequentially.isEmpty()) { 394 | sequentially.addAll(appendSequentially); 395 | } 396 | 397 | mAnimatorSet = new AnimatorSet(); 398 | mAnimatorSet.setInterpolator(new DecelerateInterpolator(1)); 399 | mAnimatorSet.playTogether(together); 400 | mAnimatorSet.playSequentially(sequentially); 401 | } 402 | 403 | private ObjectAnimator getTranslationXAnimator(float x) { 404 | if(null == mTranslationXAnimator) { 405 | mTranslationXAnimator = ObjectAnimator.ofFloat(this, "translationX", x) 406 | .setDuration(mAnimDuration); 407 | } else { 408 | mTranslationXAnimator.setFloatValues(x); 409 | } 410 | return mTranslationXAnimator; 411 | } 412 | 413 | private ObjectAnimator getTranslationYAnimator(float y) { 414 | if(null == mTranslationYAnimator) { 415 | mTranslationYAnimator = ObjectAnimator.ofFloat(this, "translationY", y) 416 | .setDuration(mAnimDuration); 417 | } else { 418 | mTranslationYAnimator.setFloatValues(y); 419 | } 420 | return mTranslationYAnimator; 421 | } 422 | 423 | private ObjectAnimator getHeightAnimator(int height) { 424 | if(null == mHeightAnimator) { 425 | mHeightAnimator = ObjectAnimator.ofInt(this, "height", getMeasuredHeight(), height) 426 | .setDuration(mAnimDuration); 427 | } else { 428 | mHeightAnimator.setIntValues(getMeasuredHeight(), height); 429 | } 430 | return mHeightAnimator; 431 | } 432 | 433 | private ObjectAnimator getWidthAnimator(int width) { 434 | if(null == mWidthAnimator) { 435 | mWidthAnimator = ObjectAnimator.ofInt(this, "width", getMeasuredWidth(), width) 436 | .setDuration(mAnimDuration); 437 | } else { 438 | mWidthAnimator.setIntValues(getMeasuredWidth(), width); 439 | } 440 | return mWidthAnimator; 441 | } 442 | 443 | private ObjectAnimator getShimmerAnimator() { 444 | if(null == mShimmerAnimator) { 445 | mShimmerAnimator = ObjectAnimator.ofFloat(this, "shimmerTranslate", -1f, 1f); 446 | mShimmerAnimator.setInterpolator(new LinearInterpolator()); 447 | mShimmerAnimator.setDuration(mShimmerDuration); 448 | mShimmerAnimator.setStartDelay(400); 449 | mShimmerAnimator.addListener(new AnimatorListenerAdapter() { 450 | @Override 451 | public void onAnimationStart(Animator animation) { 452 | setShimmerAnimating(true); 453 | } 454 | 455 | @Override 456 | public void onAnimationEnd(Animator animation) { 457 | setShimmerAnimating(false); 458 | } 459 | }); 460 | } 461 | return mShimmerAnimator; 462 | } 463 | 464 | abstract float getRoundRadius(); 465 | 466 | abstract List getTogetherAnimators(float newX, float newY, int newWidth, int newHeight, Options options); 467 | 468 | abstract List getSequentiallyAnimators(float newX, float newY, int newWidth, int newHeight, Options options); 469 | 470 | private static class RecyclerViewScrollListener extends RecyclerView.OnScrollListener { 471 | private WeakReference mFocusBorder; 472 | private int mScrolledX = 0, mScrolledY = 0; 473 | 474 | public RecyclerViewScrollListener(AbsFocusBorder border){ 475 | mFocusBorder = new WeakReference<>(border); 476 | } 477 | 478 | @Override 479 | public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 480 | mScrolledX = Math.abs(dx) == 1 ? 0 : dx; 481 | mScrolledY = Math.abs(dy) == 1 ? 0 : dy; 482 | } 483 | 484 | @Override 485 | public void onScrollStateChanged(RecyclerView recyclerView, int newState) { 486 | if(newState == RecyclerView.SCROLL_STATE_IDLE) { 487 | final AbsFocusBorder border = mFocusBorder.get(); 488 | final View focused = recyclerView.getFocusedChild(); 489 | if(null != border && null != focused) { 490 | if (border.mReAnim || mScrolledX != 0 || mScrolledY != 0) { 491 | border.runBorderAnimation(focused, Options.get(border.mScaleX, border.mScaleY)); 492 | } 493 | } 494 | mScrolledX = mScrolledY = 0; 495 | } 496 | } 497 | } 498 | 499 | public static class Options extends FocusBorder.Options{ 500 | protected float scaleX = 1f, scaleY = 1f; 501 | 502 | Options() { 503 | } 504 | 505 | private static class OptionsHolder { 506 | private static final Options INSTANCE = new Options(); 507 | } 508 | 509 | public static Options get(float scaleX, float scaleY) { 510 | OptionsHolder.INSTANCE.scaleX = scaleX; 511 | OptionsHolder.INSTANCE.scaleY = scaleY; 512 | return OptionsHolder.INSTANCE; 513 | } 514 | 515 | public boolean isScale() { 516 | return scaleX != 1f || scaleY != 1f; 517 | } 518 | } 519 | 520 | public static abstract class Builder { 521 | protected int mShimmerColor = 0x66FFFFFF; 522 | protected boolean mIsShimmerAnim = true; 523 | protected long mAnimDuration = AbsFocusBorder.DEFAULT_ANIM_DURATION_TIME; 524 | protected long mShimmerDuration = AbsFocusBorder.DEFAULT_SHIMMER_DURATION_TIME; 525 | protected RectF mPaddingOffsetRectF = new RectF(); 526 | 527 | public Builder shimmerColor(@ColorInt int color) { 528 | this.mShimmerColor = color; 529 | return this; 530 | } 531 | 532 | public Builder shimmerColorRes(@ColorRes int colorId, Context context) { 533 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 534 | this.shimmerColor(context.getColor(colorId)); 535 | } else { 536 | this.shimmerColor(context.getResources().getColor(colorId)); 537 | } 538 | return this; 539 | } 540 | 541 | public Builder shimmerDuration(long duration) { 542 | this.mShimmerDuration = duration; 543 | return this; 544 | } 545 | 546 | public Builder noShimmer() { 547 | this.mIsShimmerAnim = false; 548 | return this; 549 | } 550 | 551 | public Builder animDuration(long duration) { 552 | this.mAnimDuration = duration; 553 | return this; 554 | } 555 | 556 | public Builder padding(float padding) { 557 | return padding(padding, padding, padding, padding); 558 | } 559 | 560 | public Builder padding(float left, float top, float right, float bottom) { 561 | this.mPaddingOffsetRectF.left = left; 562 | this.mPaddingOffsetRectF.top = top; 563 | this.mPaddingOffsetRectF.right = right; 564 | this.mPaddingOffsetRectF.bottom = bottom; 565 | return this; 566 | } 567 | 568 | public FocusBorder build(android.app.Fragment fragment) { 569 | if(null != fragment.getActivity()) { 570 | return build(fragment.getActivity()); 571 | } 572 | return build((ViewGroup) fragment.getView()); 573 | } 574 | 575 | public FocusBorder build(android.support.v4.app.Fragment fragment) { 576 | if(null != fragment.getActivity()) { 577 | return build(fragment.getActivity()); 578 | } 579 | return build((ViewGroup) fragment.getView()); 580 | } 581 | 582 | public abstract FocusBorder build(Activity activity); 583 | 584 | public abstract FocusBorder build(ViewGroup viewGroup); 585 | } 586 | } 587 | -------------------------------------------------------------------------------- /tv-focusborder/src/main/java/com/owen/focus/ColorFocusBorder.java: -------------------------------------------------------------------------------- 1 | package com.owen.focus; 2 | 3 | import android.animation.Animator; 4 | import android.animation.ObjectAnimator; 5 | import android.app.Activity; 6 | import android.content.Context; 7 | import android.graphics.BlurMaskFilter; 8 | import android.graphics.Canvas; 9 | import android.graphics.Color; 10 | import android.graphics.Paint; 11 | import android.graphics.RectF; 12 | import android.graphics.Region; 13 | import android.os.Build; 14 | import android.support.annotation.ColorInt; 15 | import android.support.annotation.ColorRes; 16 | import android.support.annotation.IntDef; 17 | import android.support.v4.view.ViewCompat; 18 | import android.util.TypedValue; 19 | import android.view.ViewGroup; 20 | 21 | import java.lang.annotation.Retention; 22 | import java.lang.annotation.RetentionPolicy; 23 | import java.util.ArrayList; 24 | import java.util.List; 25 | 26 | /** 27 | * Created by owen on 2017/7/25. 28 | */ 29 | 30 | public class ColorFocusBorder extends AbsFocusBorder { 31 | private Paint mShadowPaint; 32 | private Paint mBorderPaint; 33 | private int mShadowColor = Color.RED; 34 | private float mShadowWidth = 20f; 35 | private int mBorderColor = Color.DKGRAY; 36 | private float mBorderWidth = 2f; 37 | private float mRoundRadius = 0; 38 | 39 | private ObjectAnimator mRoundRadiusAnimator; 40 | 41 | private ColorFocusBorder(Context context, int shimmerColor, long shimmerDuration, boolean isShimmerAnim, long animDuration, RectF paddingOfsetRectF, 42 | int shadowColor, float shadowWidth, int borderColor, float borderWidth) { 43 | super(context, shimmerColor, shimmerDuration, isShimmerAnim, animDuration, paddingOfsetRectF); 44 | this.mShadowColor = shadowColor; 45 | this.mShadowWidth = shadowWidth; 46 | this.mBorderColor = borderColor; 47 | this.mBorderWidth = borderWidth; 48 | 49 | final float padding = mShadowWidth + mBorderWidth; 50 | mPaddingRectF.set(padding, padding, padding, padding); 51 | initPaint(); 52 | } 53 | 54 | private void initPaint() { 55 | mShadowPaint = new Paint(); 56 | mShadowPaint.setColor(mShadowColor); 57 | // mShadowPaint.setAntiAlias(true); //抗锯齿功能,会消耗较大资源,绘制图形速度会变慢 58 | // mShadowPaint.setDither(true); //抖动处理,会使绘制出来的图片颜色更加平滑和饱满,图像更加清晰 59 | mShadowPaint.setMaskFilter(new BlurMaskFilter(mShadowWidth, BlurMaskFilter.Blur.OUTER)); 60 | 61 | mBorderPaint = new Paint(); 62 | // mBorderPaint.setAntiAlias(true); 63 | // mBorderPaint.setDither(true); 64 | mBorderPaint.setColor(mBorderColor); 65 | mBorderPaint.setStrokeWidth(mBorderWidth); 66 | mBorderPaint.setStyle(Paint.Style.STROKE); 67 | mBorderPaint.setMaskFilter(new BlurMaskFilter(0.5f, BlurMaskFilter.Blur.NORMAL)); 68 | } 69 | 70 | protected void setRoundRadius(float roundRadius) { 71 | if(mRoundRadius != roundRadius) { 72 | mRoundRadius = roundRadius; 73 | ViewCompat.postInvalidateOnAnimation(this); 74 | } 75 | } 76 | 77 | @Override 78 | public float getRoundRadius() { 79 | return mRoundRadius; 80 | } 81 | 82 | @Override 83 | List getTogetherAnimators(float newX, float newY, int newWidth, int newHeight, AbsFocusBorder.Options options) { 84 | if(options instanceof Options) { 85 | final Options rawOptions = (Options) options; 86 | List animators = new ArrayList<>(); 87 | animators.add(getRoundRadiusAnimator(rawOptions.roundRadius)); 88 | return animators; 89 | } 90 | return null; 91 | } 92 | 93 | @Override 94 | List getSequentiallyAnimators(float newX, float newY, int newWidth, int newHeight, AbsFocusBorder.Options options) { 95 | return null; 96 | } 97 | 98 | private ObjectAnimator getRoundRadiusAnimator(float roundRadius) { 99 | if(null == mRoundRadiusAnimator) { 100 | mRoundRadiusAnimator = ObjectAnimator.ofFloat(this, "roundRadius", getRoundRadius(), roundRadius); 101 | } else { 102 | mRoundRadiusAnimator.setFloatValues(getRoundRadius(), roundRadius); 103 | } 104 | return mRoundRadiusAnimator; 105 | } 106 | 107 | /** 108 | * 绘制外发光阴影 109 | * @param canvas 110 | */ 111 | private void onDrawShadow(Canvas canvas) { 112 | if(mShadowWidth > 0) { 113 | canvas.save(); 114 | //裁剪处理(使阴影矩形框内变为透明) 115 | if (mRoundRadius > 0) { 116 | canvas.clipRect(0, 0, getWidth(), getHeight()); 117 | mTempRectF.set(mFrameRectF); 118 | mTempRectF.inset(mRoundRadius / 2f, mRoundRadius / 2f); 119 | canvas.clipRect(mTempRectF, Region.Op.DIFFERENCE); 120 | } 121 | //绘制外发光阴影效果 122 | canvas.drawRoundRect(mFrameRectF, mRoundRadius, mRoundRadius, mShadowPaint); 123 | canvas.restore(); 124 | } 125 | } 126 | 127 | /** 128 | * 绘制边框 129 | * @param canvas 130 | */ 131 | private void onDrawBorder(Canvas canvas) { 132 | if(mBorderWidth > 0) { 133 | canvas.save(); 134 | mTempRectF.set(mFrameRectF); 135 | canvas.drawRoundRect(mTempRectF, mRoundRadius, mRoundRadius, mBorderPaint); 136 | canvas.restore(); 137 | } 138 | } 139 | 140 | @Override 141 | protected void onDraw(Canvas canvas) { 142 | onDrawShadow(canvas); 143 | onDrawBorder(canvas); 144 | super.onDraw(canvas); 145 | } 146 | 147 | public static class Options extends AbsFocusBorder.Options { 148 | private float roundRadius = 0f; 149 | 150 | Options() { 151 | super(); 152 | } 153 | 154 | private static class OptionsHolder { 155 | private static final Options INSTANCE = new Options(); 156 | } 157 | 158 | public static Options get(float scaleX, float scaleY, float roundRadius) { 159 | OptionsHolder.INSTANCE.scaleX = scaleX; 160 | OptionsHolder.INSTANCE.scaleY = scaleY; 161 | OptionsHolder.INSTANCE.roundRadius = roundRadius; 162 | return OptionsHolder.INSTANCE; 163 | } 164 | 165 | } 166 | 167 | @IntDef({TypedValue.COMPLEX_UNIT_PX, TypedValue.COMPLEX_UNIT_DIP, TypedValue.COMPLEX_UNIT_SP, 168 | TypedValue.COMPLEX_UNIT_PT, TypedValue.COMPLEX_UNIT_IN, TypedValue.COMPLEX_UNIT_MM}) 169 | @Retention(RetentionPolicy.SOURCE) 170 | public @interface Unit { 171 | } 172 | 173 | public final static class Builder extends AbsFocusBorder.Builder{ 174 | private int mShadowColor = 0; 175 | private int mShadowColorRes = 0; 176 | private float mShadowWidth = 0f; 177 | private int mShadowWidthUnit = -1; 178 | private float mShadowWidthSrc = 0f; 179 | private int mBorderColor = 0; 180 | private int mBorderColorRes = 0; 181 | private float mBorderWidth = 0f; 182 | private int mBorderWidthUnit = -1; 183 | private float mBorderWidthSrc = 0f; 184 | 185 | public Builder shadowColor(@ColorInt int color) { 186 | mShadowColor = color; 187 | return this; 188 | } 189 | 190 | public Builder shadowColorRes(@ColorRes int colorRes) { 191 | mShadowColorRes = colorRes; 192 | return this; 193 | } 194 | 195 | public Builder shadowWidth(float pxWidth) { 196 | mShadowWidth = pxWidth; 197 | return this; 198 | } 199 | 200 | public Builder shadowWidth(@Unit int unit, float width) { 201 | mShadowWidthUnit = unit; 202 | mShadowWidthSrc = width; 203 | return this; 204 | } 205 | 206 | public Builder borderColor(@ColorInt int color) { 207 | mBorderColor = color; 208 | return this; 209 | } 210 | 211 | public Builder borderColorRes(@ColorRes int colorRes) { 212 | mBorderColorRes = colorRes; 213 | return this; 214 | } 215 | 216 | public Builder borderWidth(float pxWidth) { 217 | mBorderWidth = pxWidth; 218 | return this; 219 | } 220 | 221 | public Builder borderWidth(@Unit int unit, float width) { 222 | mBorderWidthUnit = unit; 223 | mBorderWidthSrc = width; 224 | return this; 225 | } 226 | 227 | @Override 228 | public FocusBorder build(Activity activity) { 229 | if(null == activity) { 230 | throw new NullPointerException("The activity cannot be null"); 231 | } 232 | ViewGroup parent = activity.findViewById(android.R.id.content); 233 | return build(parent); 234 | } 235 | 236 | @Override 237 | public FocusBorder build(ViewGroup parent) { 238 | if(null == parent) { 239 | throw new NullPointerException("The FocusBorder parent cannot be null"); 240 | } 241 | 242 | if(mBorderWidthUnit >= 0 && mBorderWidthSrc > 0) { 243 | mBorderWidth = TypedValue.applyDimension( 244 | mBorderWidthUnit, mBorderWidthSrc, parent.getResources().getDisplayMetrics()); 245 | } 246 | if(mShadowWidthUnit >= 0 && mShadowWidthSrc > 0) { 247 | mShadowWidth = TypedValue.applyDimension( 248 | mShadowWidthUnit, mShadowWidthSrc, parent.getResources().getDisplayMetrics()); 249 | } 250 | if(mBorderColorRes > 0) { 251 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 252 | mBorderColor = parent.getResources().getColor(mBorderColorRes, parent.getContext().getTheme()); 253 | } else { 254 | mBorderColor = parent.getResources().getColor(mBorderColorRes); 255 | } 256 | } 257 | if(mShadowColorRes > 0) { 258 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 259 | mShadowColor = parent.getResources().getColor(mShadowColorRes, parent.getContext().getTheme()); 260 | } else { 261 | mShadowColor = parent.getResources().getColor(mShadowColorRes); 262 | } 263 | } 264 | 265 | final ColorFocusBorder borderView = new ColorFocusBorder(parent.getContext(), 266 | mShimmerColor, mShimmerDuration, mIsShimmerAnim, mAnimDuration, mPaddingOffsetRectF, 267 | mShadowColor, mShadowWidth, mBorderColor, mBorderWidth); 268 | final ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(1,1); 269 | parent.addView(borderView, lp); 270 | return borderView; 271 | } 272 | } 273 | 274 | } 275 | -------------------------------------------------------------------------------- /tv-focusborder/src/main/java/com/owen/focus/DrawableFocusBorder.java: -------------------------------------------------------------------------------- 1 | package com.owen.focus; 2 | 3 | import android.animation.Animator; 4 | import android.app.Activity; 5 | import android.content.Context; 6 | import android.graphics.Canvas; 7 | import android.graphics.Rect; 8 | import android.graphics.RectF; 9 | import android.graphics.drawable.Drawable; 10 | import android.os.Build; 11 | import android.support.annotation.DrawableRes; 12 | import android.view.ViewGroup; 13 | 14 | import java.util.List; 15 | 16 | /** 17 | * Created by owen on 2017/7/25. 18 | */ 19 | 20 | public class DrawableFocusBorder extends AbsFocusBorder { 21 | private Drawable mBorderDrawable; 22 | 23 | private DrawableFocusBorder(Context context, int shimmerColor, long shimmerDuration, boolean isShimmerAnim, long animDuration, RectF paddingOfsetRectF, Drawable borderDrawable) { 24 | super(context, shimmerColor, shimmerDuration, isShimmerAnim, animDuration, paddingOfsetRectF); 25 | 26 | this.mBorderDrawable = borderDrawable; 27 | final Rect paddingRect = new Rect(); 28 | mBorderDrawable.getPadding(paddingRect); 29 | mPaddingRectF.set(paddingRect); 30 | 31 | if(Build.VERSION.SDK_INT >= 16) { 32 | setBackground(mBorderDrawable); 33 | } else { 34 | setBackgroundDrawable(mBorderDrawable); 35 | } 36 | } 37 | 38 | @Override 39 | public float getRoundRadius() { 40 | return 0; 41 | } 42 | 43 | @Override 44 | List getTogetherAnimators(float newX, float newY, int newWidth, int newHeight, AbsFocusBorder.Options options) { 45 | return null; 46 | } 47 | 48 | @Override 49 | List getSequentiallyAnimators(float newX, float newY, int newWidth, int newHeight, AbsFocusBorder.Options options) { 50 | return null; 51 | } 52 | 53 | 54 | @Override 55 | protected void onDraw(Canvas canvas) { 56 | super.onDraw(canvas); 57 | } 58 | 59 | public final static class Builder extends AbsFocusBorder.Builder{ 60 | private int mBorderResId = 0; 61 | private Drawable mBorderDrawable = null; 62 | 63 | public Builder borderDrawableRes(@DrawableRes int resId) { 64 | mBorderResId = resId; 65 | return this; 66 | } 67 | 68 | public Builder borderDrawable(Drawable drawable) { 69 | mBorderDrawable = drawable; 70 | return this; 71 | } 72 | 73 | @Override 74 | public FocusBorder build(Activity activity) { 75 | if(null == activity) { 76 | throw new NullPointerException("The activity cannot be null"); 77 | } 78 | if(null == mBorderDrawable && mBorderResId == 0) { 79 | throw new RuntimeException("The border Drawable or ResId cannot be null"); 80 | } 81 | final ViewGroup parent = activity.findViewById(android.R.id.content); 82 | return build(parent); 83 | } 84 | 85 | @Override 86 | public FocusBorder build(ViewGroup parent) { 87 | if(null == parent) { 88 | throw new NullPointerException("The FocusBorder parent cannot be null"); 89 | } 90 | final Drawable drawable = null != mBorderDrawable ? mBorderDrawable : 91 | Build.VERSION.SDK_INT >= 21 ? parent.getContext().getDrawable(mBorderResId) 92 | : parent.getContext().getResources().getDrawable(mBorderResId); 93 | final DrawableFocusBorder boriderView = new DrawableFocusBorder( 94 | parent.getContext(), mShimmerColor, mShimmerDuration, mIsShimmerAnim, 95 | mAnimDuration, mPaddingOffsetRectF, drawable); 96 | final ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(1,1); 97 | parent.addView(boriderView, lp); 98 | return boriderView; 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tv-focusborder/src/main/java/com/owen/focus/FocusBorder.java: -------------------------------------------------------------------------------- 1 | package com.owen.focus; 2 | 3 | import android.support.annotation.NonNull; 4 | import android.view.View; 5 | 6 | /** 7 | * @author ZhouSuQiang 8 | * @date 2017/11/6 9 | */ 10 | public interface FocusBorder { 11 | 12 | void setVisible(boolean visible); 13 | 14 | boolean isVisible(); 15 | 16 | void onFocus(@NonNull View focusView, Options options); 17 | 18 | void boundGlobalFocusListener(@NonNull OnFocusCallback callback); 19 | 20 | void unBoundGlobalFocusListener(); 21 | 22 | interface OnFocusCallback { 23 | Options onFocus(View oldFocus, View newFocus); 24 | } 25 | 26 | abstract class Options {} 27 | 28 | class Builder { 29 | public final ColorFocusBorder.Builder asColor() { 30 | return new ColorFocusBorder.Builder(); 31 | } 32 | 33 | public final DrawableFocusBorder.Builder asDrawable() { 34 | return new DrawableFocusBorder.Builder(); 35 | } 36 | } 37 | 38 | class OptionsFactory { 39 | public static final Options get(float scaleX, float scaleY) { 40 | return AbsFocusBorder.Options.get(scaleX, scaleY); 41 | } 42 | 43 | public static final Options get(float scaleX, float scaleY, float roundRadius) { 44 | return ColorFocusBorder.Options.get(scaleX, scaleY, roundRadius); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tv-focusborder/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | FocusBorder 3 | 4 | --------------------------------------------------------------------------------