Android实现支持所有View的通用的下拉刷新控件
作者:AItsuki 发布时间:2022-07-29 10:40:36
下拉刷新对于一个app来说是必不可少的一个功能,在早期大多数使用的是chrisbanes的PullToRefresh,或是修改自该框架的其他库。而到现在已经有了更多的选择,github上还是有很多体验不错的下拉刷新。
而下拉刷新主要有两种实现方式:
1. 在ListView中添加header和footer,监听ListView的滑动事件,动态设置header/footer的高度,但是这种方式只适用于ListView,RecyclerView。
2. 第二种方式则是继承ViewGroup或其子类,监听事件,通过scroll或Layout的方式移动child。如图(又分两种情况)
Layout时将header放到屏幕外面,target则填充满屏幕。这个也是SwipeRefreshLayout的实现原理(第二种,只下拉header)
这两种(指的是继承ListView或继承ViewGroup)下拉刷新的实现方式主要有以下区别
而今天,我打算先讲第二种方式实现方式,继承ViewGroup,代码可以直接参考SwipeRefreshLayout,或者pullToRefresh,或者ultra-pull-to-refresh
一、思考和需求
下拉刷新需要几个状态:Reset–> Pull – > Refreshing – >Completed –>Reset
为了应对各式各样的下拉刷新设计,我们应该提供设置自定义的Header,开发者可以通过实现接口从而自定义自己的header。
而且header可以有两种显示方式,一种是只下拉header,另外一种则是header和target一起下拉。
二、着手实现代码
2.1 定义Header的接口,创建自定义Layout
/**
* Created by AItsuki on 2016/6/13.
*
*/
public enum State {
RESET, PULL, LOADING, COMPLETE
}
/**
* Created by AItsuki on 2016/6/13.
*
*/
public interface RefreshHeader {
/**
* 松手,头部隐藏后会回调这个方法
*/
void reset();
/**
* 下拉出头部的一瞬间调用
*/
void pull();
/**
* 正在刷新的时候调用
*/
void refreshing();
/**
* 头部滚动的时候持续调用
* @param currentPos target当前偏移高度
* @param lastPos target上一次的偏移高度
* @param refreshPos 可以松手刷新的高度
* @param isTouch 手指是否按下状态(通过scroll自动滚动时需要判断)
* @param state 当前状态
*/
void onPositionChange(float currentPos, float lastPos, float refreshPos, boolean isTouch, State state);
/**
* 刷新成功的时候调用
*/
void complete();
}
package com.aitsuki.custompulltorefresh;
import android.content.Context;
import android.graphics.Color;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.widget.ImageView;
/**
* Created by AItsuki on 2016/6/13.
* -
*/
public class RefreshLayout extends ViewGroup {
private View refreshHeader;
private View target;
private int currentTargetOffsetTop; // target偏移距离
private boolean hasMeasureHeader; // 是否已经计算头部高度
private int touchSlop;
private int headerHeight; // header高度
private int totalDragDistance; // 需要下拉这个距离才进入松手刷新状态,默认和header高度一致
public RefreshLayout(Context context) {
this(context, null);
}
public RefreshLayout(Context context, AttributeSet attrs) {
super(context, attrs);
touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
// 添加默认的头部,先简单的用一个ImageView代替头部
ImageView imageView = new ImageView(context);
imageView.setImageResource(R.drawable.one_piece);
imageView.setBackgroundColor(Color.BLACK);
setRefreshHeader(imageView);
}
/**
* 设置自定义header
*/
public void setRefreshHeader(View view) {
if (view != null && view != refreshHeader) {
removeView(refreshHeader);
// 为header添加默认的layoutParams
ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
if (layoutParams == null) {
layoutParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
view.setLayoutParams(layoutParams);
}
refreshHeader = view;
addView(refreshHeader);
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (target == null) {
ensureTarget();
}
if (target == null) {
return;
}
// ----- measure target -----
// target占满整屏
target.measure(MeasureSpec.makeMeasureSpec(
getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(
getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY));
// ----- measure refreshView-----
measureChild(refreshHeader, widthMeasureSpec, heightMeasureSpec);
if (!hasMeasureHeader) { // 防止header重复测量
hasMeasureHeader = true;
headerHeight = refreshHeader.getMeasuredHeight(); // header高度
totalDragDistance = headerHeight; // 需要pull这个距离才进入松手刷新状态
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
final int width = getMeasuredWidth();
final int height = getMeasuredHeight();
if (getChildCount() == 0) {
return;
}
if (target == null) {
ensureTarget();
}
if (target == null) {
return;
}
// onLayout执行的时候,要让target和header加上偏移距离(初始0),因为有可能在滚动它们的时候,child请求重新布局,从而导致target和header瞬间回到原位。
// target铺满屏幕
final View child = target;
final int childLeft = getPaddingLeft();
final int childTop = getPaddingTop() + currentTargetOffsetTop;
final int childWidth = width - getPaddingLeft() - getPaddingRight();
final int childHeight = height - getPaddingTop() - getPaddingBottom();
child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight);
// header放到target的上方,水平居中
int refreshViewWidth = refreshHeader.getMeasuredWidth();
refreshHeader.layout((width / 2 - refreshViewWidth / 2),
-headerHeight + currentTargetOffsetTop,
(width / 2 + refreshViewWidth / 2),
currentTargetOffsetTop);
}
/**
* 将第一个Child作为target
*/
private void ensureTarget() {
// Don't bother getting the parent height if the parent hasn't been laid
// out yet.
if (target == null) {
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (!child.equals(refreshHeader)) {
target = child;
break;
}
}
}
}
}
MainActivity中的布局如下,先用一个TextView作为Target
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.aitsuki.custompulltorefresh.MainActivity">
<com.aitsuki.custompulltorefresh.RefreshLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="Target"
android:textSize="30sp"
android:gravity="center"
android:background="#FFDAB9"
/>
</com.aitsuki.custompulltorefresh.RefreshLayout>
</FrameLayout>
运行后结果如图如下,但是我们还没有监听事件,所以此时还无法滑动。
2.2 处理事件分发
控件已经测量布局好了,现在就开始处理事件分发,对于事件分发还不了解的应该先去复习下……
对于多点触控的处理:
记录活动手指的id(activePointerId),通过此ID获取move事件的坐标。
1.在手指按下的时候,记录下activePointerId
2.第二根手指按下的时候,更新activePointerId。(我们让第二根手指作为活动手指,忽略第一个手指的move)
3.当其中一根手指抬起时,如果是第一根手指,那么不做处理,如果是第二根手指抬起,也就是活动手指抬起的话,将活动手指改回第一根。
对于事件分发一般有两种处理方式
1. 在onIntercept + onTouchEvnet中处理
2. 在dispatchTouchEvent中处理
在这里我选择了第二种方式
首先了解DispatchTouchEvent返回值的含义
重写dispatchTouchEvent的时候,无论你是return true,亦或是return false都会导致child接受不到事件。
return true : 告诉parent,这个事件我消费了。如果这个是down事件,那么我就会作为一个target或者说handle(事件持有者),后续的move事件或者up事件等,都会直接分发到我这里,不继续往下分发。
return false:告诉parent,这个事件我不需要,那么会交回给parent的onTouchEvnet处理
只有return super.dispatchTouchEvent的时候才会将事件继续往下传递。
上面只说了最简单的一点,如果对事件分发不了解的话需要看看,真的很重要。
分析
在dispatch中,即使child响应了事件,我们也能拿到所有事件。
这样我们就可以很简单的控制头部是否能下拉,那么如何拦截child的事件呢?
可以在合适的时候分发一个cancel事件给child,那么就相当于拦截了!
虽然我们一直都响应着事件,但肯定是不能所有事件都接收的,以下情况是需要我们处理的
1.如果是下拉,并且child不能往上滚动
2.如果上划,并且target不在顶部的时候
3.如果是这些时候,我们拦截child的事件(派发cancel事件)
代码如下
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (!isEnabled() || target == null) {
return super.dispatchTouchEvent(ev);
}
final int actionMasked = ev.getActionMasked(); // support Multi-touch
switch (actionMasked) {
case MotionEvent.ACTION_DOWN:
Log.e(TAG, "ACTION_DOWN");
activePointerId = ev.getPointerId(0);
isTouch = true; // 手指是否按下
hasSendCancelEvent = false;
mIsBeginDragged = false; // 是否开始下拉
lastTargetOffsetTop = currentTargetOffsetTop; // 上一次target的偏移高度
currentTargetOffsetTop = target.getTop(); // 当前target偏移高度
initDownX = lastMotionX = ev.getX(0); // 手指按下时的坐标
initDownY = lastMotionY = ev.getY(0);
super.dispatchTouchEvent(ev);
return true; // return true,否则可能接收不到move和up事件
case MotionEvent.ACTION_MOVE:
if (activePointerId == INVALID_POINTER) {
Log.e(TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
return super.dispatchTouchEvent(ev);
}
lastEvent = ev; // 最后一次move事件
float x = ev.getX(MotionEventCompat.findPointerIndex(ev,activePointerId));
float y = ev.getY(MotionEventCompat.findPointerIndex(ev,activePointerId));
float xDiff = x - lastMotionX;
float yDiff = y - lastMotionY;
float offsetY = yDiff * DRAG_RATE;
lastMotionX = x;
lastMotionY = y;
if(!mIsBeginDragged && Math.abs(y - initDownY) > touchSlop) {
mIsBeginDragged = true;
}
if (mIsBeginDragged) {
boolean moveDown = offsetY > 0; // ↓
boolean canMoveDown = canChildScrollUp();
boolean moveUp = !moveDown; // ↑
boolean canMoveUp = currentTargetOffsetTop > START_POSITION;
// 判断是否拦截事件
if ((moveDown && !canMoveDown) || (moveUp && canMoveUp)) {
moveSpinner(offsetY);
return true;
}
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
isTouch = false;
activePointerId = INVALID_POINTER;
break;
case MotionEvent.ACTION_POINTER_DOWN:
int pointerIndex = MotionEventCompat.getActionIndex(ev);
if (pointerIndex < 0) {
Log.e(TAG, "Got ACTION_POINTER_DOWN event but have an invalid action index.");
return super.dispatchTouchEvent(ev);
}
lastMotionX = ev.getX(pointerIndex);
lastMotionY = ev.getY(pointerIndex);
activePointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
break;
case MotionEvent.ACTION_POINTER_UP:
onSecondaryPointerUp(ev);
lastMotionY = ev.getY(ev.findPointerIndex(activePointerId));
lastMotionX = ev.getX(ev.findPointerIndex(activePointerId));
break;
}
return super.dispatchTouchEvent(ev);
}
private void onSecondaryPointerUp(MotionEvent ev) {
final int pointerIndex = MotionEventCompat.getActionIndex(ev);
final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
if (pointerId == activePointerId) {
// This was our active pointer going up. Choose a new
// active pointer and adjust accordingly.
final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
lastMotionY = ev.getY(newPointerIndex);
lastMotionX = ev.getX(newPointerIndex);
activePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
}
}
public boolean canChildScrollUp() {
if (android.os.Build.VERSION.SDK_INT < 14) {
if (target instanceof AbsListView) {
final AbsListView absListView = (AbsListView) target;
return absListView.getChildCount() > 0
&& (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
.getTop() < absListView.getPaddingTop());
} else {
return ViewCompat.canScrollVertically(target, -1) || target.getScrollY() > 0;
}
} else {
return ViewCompat.canScrollVertically(target, -1);
}
}
private void moveSpinner(float diff) {
int offset = Math.round(diff);
if (offset == 0) {
return;
}
// 发送cancel事件给child
if (!hasSendCancelEvent && isTouch && currentTargetOffsetTop > START_POSITION) {
sendCancelEvent();
hasSendCancelEvent = true;
}
int targetY = Math.max(0, currentTargetOffsetTop + offset); // target不能移动到小于0的位置……
offset = targetY - currentTargetOffsetTop;
setTargetOffsetTopAndBottom(offset);
}
private void setTargetOffsetTopAndBottom(int offset) {
if (offset == 0) {
return;
}
target.offsetTopAndBottom(offset);
refreshHeader.offsetTopAndBottom(offset);
lastTargetOffsetTop = currentTargetOffsetTop;
currentTargetOffsetTop = target.getTop();
invalidate();
}
private void sendCancelEvent() {
if (lastEvent == null) {
return;
}
MotionEvent ev = MotionEvent.obtain(lastEvent);
ev.setAction(MotionEvent.ACTION_CANCEL);
super.dispatchTouchEvent(ev);
}
代码有点多,不过没关系,其实很多都是从SwipeRefreshLayout中复制过来的。
我们来看看代码运行后的效果,很不错,就是模拟器录屏有点卡=。=
换成ListView试试, 也没有问题。
多点触控也是可以的,但是模拟器我没法演示了。
2.3 添加自动滚动
头虽然可以下拉了, 但是拉下来后就不会回去了啊,我们需要在手指松开让头部自动回到原位。
可以使用动画,可以使用ValueAnimator计算距离移动,也可以使用Scroller计算距离移动。
但是选择第三种是比较好的,为什么呢。
首先如果使用动画,在回去的过程中我们无法下拉,我们想做的是一个可以在任何时候都能上下拉的,就像ListView添加头的哪种效果。
valueAnimator也是,不好停止。
但是scroller却可以使用forceFinish强行停止计算。
松开手指时,我们通过scroller计算每次移动的offset,然后调用moveSpinner即可。
在手指按下的时候,需要停止scroller。
我们先写一个内部类,封装一下滚动功能
private class AutoScroll implements Runnable {
private Scroller scroller;
private int lastY;
public AutoScroll() {
scroller = new Scroller(getContext());
}
@Override
public void run() {
boolean finished = !scroller.computeScrollOffset() || scroller.isFinished();
if (!finished) {
int currY = scroller.getCurrY();
int offset = currY - lastY;
lastY = currY;
moveSpinner(offset); // 调用此方法移动header和target
post(this);
onScrollFinish(false);
} else {
stop();
onScrollFinish(true);
}
}
public void scrollTo(int to, int duration) {
int from = currentTargetOffsetTop;
int distance = to - from;
stop();
if (distance == 0) {
return;
}
scroller.startScroll(0, 0, 0, distance, duration);
post(this);
}
private void stop() {
removeCallbacks(this);
if (!scroller.isFinished()) {
scroller.forceFinished(true);
}
lastY = 0;
}
}
然后这个是回调,暂时用户不上,但还是先写好吧。
/**
* 在scroll结束的时候会回调这个方法
* @param isForceFinish 是否是强制结束的
*/
private void onScrollFinish(boolean isForceFinish) {
}
我们在构造中初始化AutoScroll,然后分别在ActionDown和ActionUp中分别调用stop和scrollto即可,如下
case MotionEvent.ACTION_DOWN:
//...
autoScroll.stop();
//...
break
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
//...
if(currentTargetOffsetTop > START_POSITION) {
autoScroll.scrollTo(START_POSITION, SCROLL_TO_TOP_DURATION);
}
//...
运行效果如下图
2.4 添加刷新状态
最开始的时候我们也新建了一个枚举,设置了几种状态,分别是 RESET, PULL, LOADING, COMPLETE
而我们的初始状态应该为RESET
private State state = State.RESET;
再分析一下,这几种状态什么时候互相切换:
1. 在RESET状态时,第一次下拉出现header的时候,设置状态变成PULL
2. 在PULL或者COMPLETE状态时,header回到顶部的时候,状态变回RESET
3. 如果是从底部回到顶部的过程(往上滚动),并且手指是松开状态, 并且当前是PULL状态,状态变成LOADING,这时候我们需要强制停止autoScroll。并且正在刷新中的 * 也在这里调用(onRefresh())
4. 在LOADING状态中,想变成其他状态,需要提供公共方法给外部调用
首先,我们先写一个改变状态的方法,在状态改变的同时要回调给header。
private void changeState(State state) {
this.state = state;
RefreshHeader refreshHeader = this.refreshHeader instanceof RefreshHeader ? ((RefreshHeader) this.refreshHeader) : null;
if (refreshHeader != null) {
switch (state) {
case RESET:
refreshHeader.reset();
break;
case PULL:
refreshHeader.pull();
break;
case LOADING:
refreshHeader.refreshing();
break;
case COMPLETE:
refreshHeader.complete();
break;
}
}
}
还有,提供外部设置刷新成功的方法。
因为刷新成功后需要将header滚动回原位,所以需要做以下判断
1. 如果已经在原位,那么直接将状态改成Reset
2. 如果不在原位,延时500毫秒后自动滚动回原位。这里延时500毫秒是为了展示刷新成功的提示,否则在网速很快的情况下,刷新成功后header立即回到原位体验性不好,感觉就像是下拉后立即就自动回去了。
3. 在自动回滚时还需要判断当前手指是否在触摸状态,如果正在触摸,代表用户可能并不想header回去,所以这时候我们不能让头部滚动。
4. 再者就是,如果在延时的500内,用户按下了手指,我们需要将这个runnable取消,在ActionDown中RemoveCallBack即可。总的来说一句话就是,用户必须持有header的绝对控制权,在手指按下时,header不应该出现自动滚动的情况。
public void refreshComplete() {
changeState(State.COMPLETE);
// if refresh completed and the target at top, change state to reset.
if (currentTargetOffsetTop == START_POSITION) {
changeState(State.RESET);
} else {
// waiting for a time to show refreshView completed state.
// at next touch event, remove this runnable
if (!isTouch) {
postDelayed(delayToScrollTopRunnable, SHOW_COMPLETED_TIME);
}
}
}
// 刷新成功,显示500ms成功状态再滚动回顶部,这个runnalbe需要在ActionDown事件中Remove
private Runnable delayToScrollTopRunnable = new Runnable() {
@Override
public void run() {
autoScroll.scrollTo(START_POSITION, SCROLL_TO_TOP_DURATION);
}
};
提供设置正在刷新回调的方法
当用户松开手指,进入刷新状态时我们需要回调这个方法。
// 定义一个 *
public interface OnRefreshListener {
void onRefresh();
}
// 提供外部设置方法
public void setRefreshListener(OnRefreshListener refreshListener) {
this.refreshListener = refreshListener;
}
做完以上几部,我们算是完成了LOADING到COMPLETE的状态切换,余下的几个状态我们则需要在movespinner这个方法中控制,上面也已经分析过了逻辑,那么可以直接看代码了。
private void moveSpinner(float diff) {
int offset = Math.round(diff);
if (offset == 0) {
return;
}
// 发送cancel事件给child
if (!hasSendCancelEvent && isTouch && currentTargetOffsetTop > START_POSITION) {
sendCancelEvent();
hasSendCancelEvent = true;
}
int targetY = Math.max(0, currentTargetOffsetTop + offset); // target不能移动到小于0的位置……
offset = targetY - currentTargetOffsetTop;
// 1. 在RESET状态时,第一次下拉出现header的时候,设置状态变成PULL
if (state == State.RESET && currentTargetOffsetTop == START_POSITION && targetY > 0) {
changeState(State.PULL);
}
// 2. 在PULL或者COMPLETE状态时,header回到顶部的时候,状态变回RESET
if (currentTargetOffsetTop > START_POSITION && targetY <= START_POSITION) {
if (state == State.PULL || state == State.COMPLETE) {
changeState(State.RESET);
}
}
// 3. 如果是从底部回到顶部的过程(往上滚动),并且手指是松开状态, 并且当前是PULL状态,状态变成LOADING,这时候我们需要强制停止autoScroll
if (state == State.PULL && !isTouch && currentTargetOffsetTop > totalDragDistance && targetY <= totalDragDistance) {
autoScroll.stop();
changeState(State.LOADING);
if (refreshListener != null) {
refreshListener.onRefresh();
}
// 因为判断条件targetY <= totalDragDistance,会导致不能回到正确的刷新高度(有那么一丁点偏差),调整change
int adjustOffset = totalDragDistance - targetY;
offset += adjustOffset;
}
setTargetOffsetTopAndBottom(offset);
// 别忘了回调header的位置改变方法。
if(refreshHeader instanceof RefreshHeader) {
((RefreshHeader) refreshHeader)
.onPositionChange(currentTargetOffsetTop, lastTargetOffsetTop, totalDragDistance, isTouch,state);
}
}
而ActionUp的时候也不能单纯的让header回到顶部了,而是需要通过判断状态,回到刷新高度亦或是回到顶部。
1. 刷新状态,回到刷新高度
2. 否则,回到顶部
我们将原本在ActionUp中的autoScroll.scrollto(…)抽取成一个方法再调用,如下
private void finishSpinner() {
if (state == State.LOADING) {
if (currentTargetOffsetTop > totalDragDistance) {
autoScroll.scrollTo(totalDragDistance, SCROLL_TO_REFRESH_DURATION);
}
} else {
autoScroll.scrollTo(START_POSITION, SCROLL_TO_TOP_DURATION);
}
}


猜你喜欢
- 本文实例为大家分享了Android RxJava创建操作符Timer的具体代码,供大家参考,具体内容如下之前有写过Android实现倒计时之
- 今天被数据大神说了,对接第三方接口返回的json字段我想用驼峰形式,他说我这样不专业。所以就改了,认怂。记住以后再次对接rest接口,返回的
- 项目常常需要有访问共享文件夹的需求,例如共享文件夹存储照片、文件等。那么如何使用Java读写Windows共享文件夹呢?Java可以使用JC
- 我就废话不多说了,大家还是直接看代码吧~/** * feign调用客户端 */@FeignClient(name = "user&
- 一、C#正则表达式符号模式字符描述\转义字符,将一个具有特殊功能的字符转义为一个普通字符,或反过来^匹配输入字符串的开始位置$匹配输入字符串
- MVC注解式开发即处理器基于注解的类开发, 对于每一个定义的处理器, 无需在xml中注册.只需在代码中通过对类与方法的注解, 即可完成注册.
- 1.身份证规则计算方法(来源百度)将前面的身份证号码17位数分别乘以不同的系数。从第一位到第十七位的系数分别为:7-9-10-5-8-4-2
- 题外由于idea原因 用注解test无法在控制台上输入所以写死到程序里了,版本都30.9102了为什么还是这样啊,intelJ你们该反思了!
- 本文实例为大家分享了Unity Shader实现模糊效果的具体代码,供大家参考,具体内容如下今天分享一个超简单实现模糊效果的方法,先上图:核
- 在开发中当程序发生ANR或者异常,我们会将信息存在本地,然后上传服务器,这样可以实时去发现问题修改问题。那我们需要获取文件之后需要对文件进行
- Java 切割字符串的几种方式//以data 为案例参数。String data = "2019-01-
- 目录1、如果一个方法或变量是"private"访问级别,那么它的访问范围是:2、代码将打印?3、下面关于hibernat
- java函数中的传值和传引用问题一直是个比较“邪门”的问题,其实java函数中的参数都是传递值的,所不同的是对于基本数据类型传递的是参数的一
- 背景 我们知道在.NET Framework中存在四种常用的定时器,他们分别是:1 两个是通用的多线程定时器:Syste
- 一、概述现在大多数的电商APP的详情页长得几乎都差不多,几乎都是上面一个商品的图片,当你滑动的时候,会有Tab悬浮在上面,这样做用户体验确实
- 本文实例为大家分享了C#实现学生档案查询的具体代码,供大家参考,具体内容如下using System;using System.Collec
- 之前讲到了自定义Adapter传递给ListView时,因为ListView的View回收,需要注意当ListView列表项中包含有带有状态
- 介绍使用mybatis时可以使用二级缓存提高查询速度,进而改善用户体验。使用redis做mybatis的二级缓存可是内存可控<如将单独
- 前言;Apache common-pool对象池介绍:对象生命周期、Config详解、代码说明对象生命周期Config详解maxActive
- 本文实例为大家分享了java将一个目录下的所有数据复制到另一个目录下的具体代码,供大家参考,具体内容如下/* 将"C:\\Java