فهرست منبع

[feat]键盘插件,键盘的人设列表,超过一页,则横向滑动

hezihao 8 ماه پیش
والد
کامیت
16fc59141c

+ 26 - 3
plugins/keyboard_android/android/src/main/kotlin/com/atmob/keyboard_android/component/child/impl/AiKeyboardComponent.kt

@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
 import android.content.Context
 import android.util.AttributeSet
 import android.view.View
-import androidx.recyclerview.widget.GridLayoutManager
 import androidx.recyclerview.widget.RecyclerView
 import com.atmob.keyboard_android.R
 import com.atmob.keyboard_android.component.base.BaseUIComponent
@@ -28,6 +27,9 @@ import com.atmob.keyboard_android.widget.LongTouchContainer
 import com.atmob.keyboard_android.widget.indicator.TabPagerTitleView
 import com.blankj.utilcode.util.ConvertUtils
 import com.blankj.utilcode.util.ToastUtils
+import com.gcssloop.widget.PagerGridLayoutManager
+import com.gcssloop.widget.PagerGridLayoutManager.PageListener
+import com.gcssloop.widget.PagerGridSnapHelper
 import me.drakeet.multitype.Items
 import me.drakeet.multitype.MultiTypeAdapter
 import net.lucode.hackware.magicindicator.MagicIndicator
@@ -38,6 +40,7 @@ import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.IPagerInd
 import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.IPagerTitleView
 import net.lucode.hackware.magicindicator.buildins.commonnavigator.indicators.LinePagerIndicator
 
+
 /**
  * AI键盘组件
  */
@@ -179,9 +182,29 @@ class AiKeyboardComponent @JvmOverloads constructor(
                     controlAiChatPageShowing(true)
                 })
             }
-            layoutManager = GridLayoutManager(context, Constants.AI_KEYBOARD_KEY_LIST_SPAN_COUNT)
+            // 水平分页布局管理器
+            layoutManager = PagerGridLayoutManager(
+                Constants.AI_KEYBOARD_KEY_LIST_SPAN_COUNT,
+                Constants.AI_KEYBOARD_KEY_LIST_SPAN_COUNT,
+                PagerGridLayoutManager.HORIZONTAL
+            ).apply {
+                // 设置滚动监听
+                setPageListener(object : PageListener {
+                    override fun onPageSizeChanged(pageSize: Int) {
+                        // 当总页数确定时的回调
+                    }
+
+                    override fun onPageSelect(pageIndex: Int) {
+                        // 当页面被选中时的回调(从 0 开始)
+                    }
+                })
+            }
+            // 设置滚动辅助工具
+            val pageSnapHelper = PagerGridSnapHelper()
+            pageSnapHelper.attachToRecyclerView(this)
+            // 设置适配器
             adapter = mKeyListAdapter
-            // 分割线
+            // 添加分割线
             addItemDecoration(
                 GridDivider(
                     Constants.AI_KEYBOARD_KEY_LIST_SPAN_COUNT,

+ 103 - 0
plugins/keyboard_android/android/src/main/kotlin/com/gcssloop/widget/PagerConfig.java

@@ -0,0 +1,103 @@
+/*
+ * Copyright 2017 GcsSloop
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * Last modified 2017-09-20 16:32:43
+ *
+ * GitHub: https://github.com/GcsSloop
+ * WeiBo: http://weibo.com/GcsSloop
+ * WebSite: http://www.gcssloop.com
+ */
+
+package com.gcssloop.widget;
+
+import android.util.Log;
+
+/**
+ * 作用:Pager配置
+ * 作者:GcsSloop
+ * 摘要:主要用于Log的显示与关闭
+ */
+public class PagerConfig {
+    private static final String TAG = "PagerGrid";
+    private static boolean sShowLog = false;
+    private static int sFlingThreshold = 1000;          // Fling 阀值,滚动速度超过该阀值才会触发滚动
+    private static float sMillisecondsPreInch = 60f;    // 每一个英寸滚动需要的微秒数,数值越大,速度越慢
+
+    /**
+     * 判断是否输出日志
+     *
+     * @return true 输出,false 不输出
+     */
+    public static boolean isShowLog() {
+        return sShowLog;
+    }
+
+    /**
+     * 设置是否输出日志
+     *
+     * @param showLog 是否输出
+     */
+    public static void setShowLog(boolean showLog) {
+        sShowLog = showLog;
+    }
+
+    /**
+     * 获取当前滚动速度阀值
+     *
+     * @return 当前滚动速度阀值
+     */
+    public static int getFlingThreshold() {
+        return sFlingThreshold;
+    }
+
+    /**
+     * 设置当前滚动速度阀值
+     *
+     * @param flingThreshold 滚动速度阀值
+     */
+    public static void setFlingThreshold(int flingThreshold) {
+        sFlingThreshold = flingThreshold;
+    }
+
+    /**
+     * 获取滚动速度 英寸/微秒
+     *
+     * @return 英寸滚动速度
+     */
+    public static float getMillisecondsPreInch() {
+        return sMillisecondsPreInch;
+    }
+
+    /**
+     * 设置像素滚动速度 英寸/微秒
+     *
+     * @param millisecondsPreInch 英寸滚动速度
+     */
+    public static void setMillisecondsPreInch(float millisecondsPreInch) {
+        sMillisecondsPreInch = millisecondsPreInch;
+    }
+
+    //--- 日志 -------------------------------------------------------------------------------------
+
+    public static void Logi(String msg) {
+        if (!PagerConfig.isShowLog()) return;
+        Log.i(TAG, msg);
+    }
+
+    public static void Loge(String msg) {
+        if (!PagerConfig.isShowLog()) return;
+        Log.e(TAG, msg);
+    }
+}

+ 907 - 0
plugins/keyboard_android/android/src/main/kotlin/com/gcssloop/widget/PagerGridLayoutManager.java

@@ -0,0 +1,907 @@
+/*
+ * Copyright 2017 GcsSloop
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * Last modified 2017-09-20 16:32:43
+ *
+ * GitHub: https://github.com/GcsSloop
+ * WeiBo: http://weibo.com/GcsSloop
+ * WebSite: http://www.gcssloop.com
+ */
+
+package com.gcssloop.widget;
+
+import static android.view.View.MeasureSpec.EXACTLY;
+import static com.gcssloop.widget.PagerConfig.Loge;
+import static com.gcssloop.widget.PagerConfig.Logi;
+
+import android.annotation.SuppressLint;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.IntRange;
+import androidx.recyclerview.widget.LinearSmoothScroller;
+import androidx.recyclerview.widget.RecyclerView;
+
+
+/**
+ * 作用:分页的网格布局管理器
+ * 作者:GcsSloop
+ * 摘要:
+ * 1. 网格布局
+ * 2. 支持水平分页和垂直分页
+ * 3. 杜绝高内存占用
+ */
+public class PagerGridLayoutManager extends RecyclerView.LayoutManager
+        implements RecyclerView.SmoothScroller.ScrollVectorProvider {
+    private static final String TAG = PagerGridLayoutManager.class.getSimpleName();
+
+    public static final int VERTICAL = 0;           // 垂直滚动
+    public static final int HORIZONTAL = 1;         // 水平滚动
+
+    @IntDef({VERTICAL, HORIZONTAL})
+    public @interface OrientationType {}            // 滚动类型
+
+    @OrientationType
+    private int mOrientation;                       // 默认水平滚动
+
+    private int mOffsetX = 0;                       // 水平滚动距离(偏移量)
+    private int mOffsetY = 0;                       // 垂直滚动距离(偏移量)
+
+    private int mRows;                              // 行数
+    private int mColumns;                           // 列数
+    private int mOnePageSize;                       // 一页的条目数量
+
+    private SparseArray<Rect> mItemFrames;          // 条目的显示区域
+
+    private int mItemWidth = 0;                     // 条目宽度
+    private int mItemHeight = 0;                    // 条目高度
+
+    private int mWidthUsed = 0;                     // 已经使用空间,用于测量View
+    private int mHeightUsed = 0;                    // 已经使用空间,用于测量View
+
+    private int mMaxScrollX;                        // 最大允许滑动的宽度
+    private int mMaxScrollY;                        // 最大允许滑动的高度
+    private int mScrollState = RecyclerView.SCROLL_STATE_IDLE;   // 滚动状态
+
+    private boolean mAllowContinuousScroll = true;  // 是否允许连续滚动
+
+    private RecyclerView mRecyclerView;
+
+    /**
+     * 构造函数
+     *
+     * @param rows        行数
+     * @param columns     列数
+     * @param orientation 方向
+     */
+    public PagerGridLayoutManager(@IntRange(from = 1, to = 100) int rows,
+                                  @IntRange(from = 1, to = 100) int columns,
+                                  @OrientationType int orientation) {
+        mItemFrames = new SparseArray<>();
+        mOrientation = orientation;
+        mRows = rows;
+        mColumns = columns;
+        mOnePageSize = mRows * mColumns;
+    }
+
+    @Override
+    public void onAttachedToWindow(RecyclerView view) {
+        super.onAttachedToWindow(view);
+        mRecyclerView = view;
+    }
+
+    //--- 处理布局 ----------------------------------------------------------------------------------
+
+    /**
+     * 布局子View
+     *
+     * @param recycler Recycler
+     * @param state    State
+     */
+    @Override
+    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
+        Logi("Item onLayoutChildren");
+        Logi("Item onLayoutChildren isPreLayout = " + state.isPreLayout());
+        Logi("Item onLayoutChildren isMeasuring = " + state.isMeasuring());
+        Loge("Item onLayoutChildren state = " + state);
+
+        // 如果是 preLayout 则不重新布局
+        if (state.isPreLayout() || !state.didStructureChange()) {
+            return;
+        }
+
+        if (getItemCount() == 0) {
+            removeAndRecycleAllViews(recycler);
+            // 页面变化回调
+            setPageCount(0);
+            setPageIndex(0, false);
+            return;
+        } else {
+            setPageCount(getTotalPageCount());
+            setPageIndex(getPageIndexByOffset(), false);
+        }
+
+        // 计算页面数量
+        int mPageCount = getItemCount() / mOnePageSize;
+        if (getItemCount() % mOnePageSize != 0) {
+            mPageCount++;
+        }
+
+        // 计算可以滚动的最大数值,并对滚动距离进行修正
+        if (canScrollHorizontally()) {
+            mMaxScrollX = (mPageCount - 1) * getUsableWidth();
+            mMaxScrollY = 0;
+            if (mOffsetX > mMaxScrollX) {
+                mOffsetX = mMaxScrollX;
+            }
+        } else {
+            mMaxScrollX = 0;
+            mMaxScrollY = (mPageCount - 1) * getUsableHeight();
+            if (mOffsetY > mMaxScrollY) {
+                mOffsetY = mMaxScrollY;
+            }
+        }
+
+        // 接口回调
+        // setPageCount(mPageCount);
+        // setPageIndex(mCurrentPageIndex, false);
+
+        Logi("count = " + getItemCount());
+
+        if (mItemWidth <= 0) {
+            mItemWidth = getUsableWidth() / mColumns;
+        }
+        if (mItemHeight <= 0) {
+            mItemHeight = getUsableHeight() / mRows;
+        }
+
+        mWidthUsed = getUsableWidth() - mItemWidth;
+        mHeightUsed = getUsableHeight() - mItemHeight;
+
+        // 预存储两页的View显示区域
+        for (int i = 0; i < mOnePageSize * 2; i++) {
+            getItemFrameByPosition(i);
+        }
+
+        if (mOffsetX == 0 && mOffsetY == 0) {
+            // 预存储View
+            for (int i = 0; i < mOnePageSize; i++) {
+                if (i >= getItemCount()) break; // 防止数据过少时导致数组越界异常
+                View view = recycler.getViewForPosition(i);
+                addView(view);
+                measureChildWithMargins(view, mWidthUsed, mHeightUsed);
+            }
+        }
+
+        // 回收和填充布局
+        recycleAndFillItems(recycler, state, true);
+    }
+
+    /**
+     * 布局结束
+     *
+     * @param state State
+     */
+    @Override
+    public void onLayoutCompleted(RecyclerView.State state) {
+        super.onLayoutCompleted(state);
+        if (state.isPreLayout()) return;
+        // 页面状态回调
+        setPageCount(getTotalPageCount());
+        setPageIndex(getPageIndexByOffset(), false);
+    }
+
+    /**
+     * 回收和填充布局
+     *
+     * @param recycler Recycler
+     * @param state    State
+     * @param isStart  是否从头开始,用于控制View遍历方向,true 为从头到尾,false 为从尾到头
+     */
+    @SuppressLint("CheckResult")
+    private void recycleAndFillItems(RecyclerView.Recycler recycler, RecyclerView.State state,
+                                     boolean isStart) {
+        if (state.isPreLayout()) {
+            return;
+        }
+
+        Logi("mOffsetX = " + mOffsetX);
+        Logi("mOffsetY = " + mOffsetY);
+
+        // 计算显示区域区前后多存储一列或则一行
+        Rect displayRect = new Rect(mOffsetX - mItemWidth, mOffsetY - mItemHeight,
+                getUsableWidth() + mOffsetX + mItemWidth, getUsableHeight() + mOffsetY + mItemHeight);
+        // 对显显示区域进行修正(计算当前显示区域和最大显示区域对交集)
+        displayRect.intersect(0, 0, mMaxScrollX + getUsableWidth(), mMaxScrollY + getUsableHeight());
+        Loge("displayRect = " + displayRect.toString());
+
+        int startPos = 0;                  // 获取第一个条目的Pos
+        int pageIndex = getPageIndexByOffset();
+        startPos = pageIndex * mOnePageSize;
+        Logi("startPos = " + startPos);
+        startPos = startPos - mOnePageSize * 2;
+        if (startPos < 0) {
+            startPos = 0;
+        }
+        int stopPos = startPos + mOnePageSize * 4;
+        if (stopPos > getItemCount()) {
+            stopPos = getItemCount();
+        }
+
+        Loge("startPos = " + startPos);
+        Loge("stopPos = " + stopPos);
+
+        detachAndScrapAttachedViews(recycler); // 移除所有View
+
+        if (isStart) {
+            for (int i = startPos; i < stopPos; i++) {
+                addOrRemove(recycler, displayRect, i);
+            }
+        } else {
+            for (int i = stopPos - 1; i >= startPos; i--) {
+                addOrRemove(recycler, displayRect, i);
+            }
+        }
+        Loge("child count = " + getChildCount());
+    }
+
+    /**
+     * 添加或者移除条目
+     *
+     * @param recycler    RecyclerView
+     * @param displayRect 显示区域
+     * @param i           条目下标
+     */
+    private void addOrRemove(RecyclerView.Recycler recycler, Rect displayRect, int i) {
+        View child = recycler.getViewForPosition(i);
+        Rect rect = getItemFrameByPosition(i);
+        if (!Rect.intersects(displayRect, rect)) {
+            removeAndRecycleView(child, recycler);   // 回收入暂存区
+        } else {
+            addView(child);
+            measureChildWithMargins(child, mWidthUsed, mHeightUsed);
+            RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child.getLayoutParams();
+            layoutDecorated(child,
+                    rect.left - mOffsetX + lp.leftMargin + getPaddingLeft(),
+                    rect.top - mOffsetY + lp.topMargin + getPaddingTop(),
+                    rect.right - mOffsetX - lp.rightMargin + getPaddingLeft(),
+                    rect.bottom - mOffsetY - lp.bottomMargin + getPaddingTop());
+        }
+    }
+
+
+    //--- 处理滚动 ----------------------------------------------------------------------------------
+
+    /**
+     * 水平滚动
+     *
+     * @param dx       滚动距离
+     * @param recycler 回收器
+     * @param state    滚动状态
+     * @return 实际滚动距离
+     */
+    @Override
+    public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State
+            state) {
+        int newX = mOffsetX + dx;
+        int result = dx;
+        if (newX > mMaxScrollX) {
+            result = mMaxScrollX - mOffsetX;
+        } else if (newX < 0) {
+            result = 0 - mOffsetX;
+        }
+        mOffsetX += result;
+        setPageIndex(getPageIndexByOffset(), true);
+        offsetChildrenHorizontal(-result);
+        if (result > 0) {
+            recycleAndFillItems(recycler, state, true);
+        } else {
+            recycleAndFillItems(recycler, state, false);
+        }
+        return result;
+    }
+
+    /**
+     * 垂直滚动
+     *
+     * @param dy       滚动距离
+     * @param recycler 回收器
+     * @param state    滚动状态
+     * @return 实际滚动距离
+     */
+    @Override
+    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State
+            state) {
+        int newY = mOffsetY + dy;
+        int result = dy;
+        if (newY > mMaxScrollY) {
+            result = mMaxScrollY - mOffsetY;
+        } else if (newY < 0) {
+            result = 0 - mOffsetY;
+        }
+        mOffsetY += result;
+        setPageIndex(getPageIndexByOffset(), true);
+        offsetChildrenVertical(-result);
+        if (result > 0) {
+            recycleAndFillItems(recycler, state, true);
+        } else {
+            recycleAndFillItems(recycler, state, false);
+        }
+        return result;
+    }
+
+    /**
+     * 监听滚动状态,滚动结束后通知当前选中的页面
+     *
+     * @param state 滚动状态
+     */
+    @Override
+    public void onScrollStateChanged(int state) {
+        Logi("onScrollStateChanged = " + state);
+        mScrollState = state;
+        super.onScrollStateChanged(state);
+        if (state == RecyclerView.SCROLL_STATE_IDLE) {
+            setPageIndex(getPageIndexByOffset(), false);
+        }
+    }
+
+
+    //--- 私有方法 ----------------------------------------------------------------------------------
+
+    /**
+     * 获取条目显示区域
+     *
+     * @param pos 位置下标
+     * @return 显示区域
+     */
+    private Rect getItemFrameByPosition(int pos) {
+        Rect rect = mItemFrames.get(pos);
+        if (null == rect) {
+            rect = new Rect();
+            // 计算显示区域 Rect
+
+            // 1. 获取当前View所在页数
+            int page = pos / mOnePageSize;
+
+            // 2. 计算当前页数左上角的总偏移量
+            int offsetX = 0;
+            int offsetY = 0;
+            if (canScrollHorizontally()) {
+                offsetX += getUsableWidth() * page;
+            } else {
+                offsetY += getUsableHeight() * page;
+            }
+
+            // 3. 根据在当前页面中的位置确定具体偏移量
+            int pagePos = pos % mOnePageSize;       // 在当前页面中是第几个
+            int row = pagePos / mColumns;           // 获取所在行
+            int col = pagePos - (row * mColumns);   // 获取所在列
+
+            offsetX += col * mItemWidth;
+            offsetY += row * mItemHeight;
+
+            // 状态输出,用于调试
+            Logi("pagePos = " + pagePos);
+            Logi("行 = " + row);
+            Logi("列 = " + col);
+
+            Logi("offsetX = " + offsetX);
+            Logi("offsetY = " + offsetY);
+
+            rect.left = offsetX;
+            rect.top = offsetY;
+            rect.right = offsetX + mItemWidth;
+            rect.bottom = offsetY + mItemHeight;
+
+            // 存储
+            mItemFrames.put(pos, rect);
+        }
+        return rect;
+    }
+
+    /**
+     * 获取可用的宽度
+     *
+     * @return 宽度 - padding
+     */
+    private int getUsableWidth() {
+        return getWidth() - getPaddingLeft() - getPaddingRight();
+    }
+
+    /**
+     * 获取可用的高度
+     *
+     * @return 高度 - padding
+     */
+    private int getUsableHeight() {
+        return getHeight() - getPaddingTop() - getPaddingBottom();
+    }
+
+
+    //--- 页面相关(私有) -----------------------------------------------------------------------------
+
+    /**
+     * 获取总页数
+     */
+    private int getTotalPageCount() {
+        if (getItemCount() <= 0) return 0;
+        int totalCount = getItemCount() / mOnePageSize;
+        if (getItemCount() % mOnePageSize != 0) {
+            totalCount++;
+        }
+        return totalCount;
+    }
+
+    /**
+     * 根据pos,获取该View所在的页面
+     *
+     * @param pos position
+     * @return 页面的页码
+     */
+    private int getPageIndexByPos(int pos) {
+        return pos / mOnePageSize;
+    }
+
+    /**
+     * 根据 offset 获取页面Index
+     *
+     * @return 页面 Index
+     */
+    private int getPageIndexByOffset() {
+        int pageIndex;
+        if (canScrollVertically()) {
+            int pageHeight = getUsableHeight();
+            if (mOffsetY <= 0 || pageHeight <= 0) {
+                pageIndex = 0;
+            } else {
+                pageIndex = mOffsetY / pageHeight;
+                if (mOffsetY % pageHeight > pageHeight / 2) {
+                    pageIndex++;
+                }
+            }
+        } else {
+            int pageWidth = getUsableWidth();
+            if (mOffsetX <= 0 || pageWidth <= 0) {
+                pageIndex = 0;
+            } else {
+                pageIndex = mOffsetX / pageWidth;
+                if (mOffsetX % pageWidth > pageWidth / 2) {
+                    pageIndex++;
+                }
+            }
+        }
+        Logi("getPageIndexByOffset pageIndex = " + pageIndex);
+        return pageIndex;
+    }
+
+
+    //--- 公开方法 ----------------------------------------------------------------------------------
+
+    /**
+     * 创建默认布局参数
+     *
+     * @return 默认布局参数
+     */
+    @Override
+    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
+        return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
+                ViewGroup.LayoutParams.WRAP_CONTENT);
+    }
+
+    /**
+     * 处理测量逻辑
+     *
+     * @param recycler          RecyclerView
+     * @param state             状态
+     * @param widthMeasureSpec  宽度属性
+     * @param heightMeasureSpec 高估属性
+     */
+    @Override
+    public void onMeasure(RecyclerView.Recycler recycler, RecyclerView.State state, int widthMeasureSpec, int heightMeasureSpec) {
+        super.onMeasure(recycler, state, widthMeasureSpec, heightMeasureSpec);
+        int widthsize = View.MeasureSpec.getSize(widthMeasureSpec);      //取出宽度的确切数值
+        int widthmode = View.MeasureSpec.getMode(widthMeasureSpec);      //取出宽度的测量模式
+
+        int heightsize = View.MeasureSpec.getSize(heightMeasureSpec);    //取出高度的确切数值
+        int heightmode = View.MeasureSpec.getMode(heightMeasureSpec);    //取出高度的测量模式
+
+        // 将 wrap_content 转换为 match_parent
+        if (widthmode != EXACTLY && widthsize > 0) {
+            widthmode = EXACTLY;
+        }
+        if (heightmode != EXACTLY && heightsize > 0) {
+            heightmode = EXACTLY;
+        }
+        setMeasuredDimension(View.MeasureSpec.makeMeasureSpec(widthsize, widthmode),
+                View.MeasureSpec.makeMeasureSpec(heightsize, heightmode));
+    }
+
+    /**
+     * 是否可以水平滚动
+     *
+     * @return true 是,false 不是。
+     */
+    @Override
+    public boolean canScrollHorizontally() {
+        return mOrientation == HORIZONTAL;
+    }
+
+    /**
+     * 是否可以垂直滚动
+     *
+     * @return true 是,false 不是。
+     */
+    @Override
+    public boolean canScrollVertically() {
+        return mOrientation == VERTICAL;
+    }
+
+    /**
+     * 找到下一页第一个条目的位置
+     *
+     * @return 第一个搞条目的位置
+     */
+    int findNextPageFirstPos() {
+        int page = mLastPageIndex;
+        page++;
+        if (page >= getTotalPageCount()) {
+            page = getTotalPageCount() - 1;
+        }
+        Loge("computeScrollVectorForPosition next = " + page);
+        return page * mOnePageSize;
+    }
+
+    /**
+     * 找到上一页的第一个条目的位置
+     *
+     * @return 第一个条目的位置
+     */
+    int findPrePageFirstPos() {
+        // 在获取时由于前一页的View预加载出来了,所以获取到的直接就是前一页
+        int page = mLastPageIndex;
+        page--;
+        Loge("computeScrollVectorForPosition pre = " + page);
+        if (page < 0) {
+            page = 0;
+        }
+        Loge("computeScrollVectorForPosition pre = " + page);
+        return page * mOnePageSize;
+    }
+
+    /**
+     * 获取当前 X 轴偏移量
+     *
+     * @return X 轴偏移量
+     */
+    public int getOffsetX() {
+        return mOffsetX;
+    }
+
+    /**
+     * 获取当前 Y 轴偏移量
+     *
+     * @return Y 轴偏移量
+     */
+    public int getOffsetY() {
+        return mOffsetY;
+    }
+
+
+    //--- 页面对齐 ----------------------------------------------------------------------------------
+
+    /**
+     * 计算到目标位置需要滚动的距离{@link RecyclerView.SmoothScroller.ScrollVectorProvider}
+     *
+     * @param targetPosition 目标控件
+     * @return 需要滚动的距离
+     */
+    @Override
+    public PointF computeScrollVectorForPosition(int targetPosition) {
+        PointF vector = new PointF();
+        int[] pos = getSnapOffset(targetPosition);
+        vector.x = pos[0];
+        vector.y = pos[1];
+        return vector;
+    }
+
+    /**
+     * 获取偏移量(为PagerGridSnapHelper准备)
+     * 用于分页滚动,确定需要滚动的距离。
+     * {@link PagerGridSnapHelper}
+     *
+     * @param targetPosition 条目下标
+     */
+    int[] getSnapOffset(int targetPosition) {
+        int[] offset = new int[2];
+        int[] pos = getPageLeftTopByPosition(targetPosition);
+        offset[0] = pos[0] - mOffsetX;
+        offset[1] = pos[1] - mOffsetY;
+        return offset;
+    }
+
+    /**
+     * 根据条目下标获取该条目所在页面的左上角位置
+     *
+     * @param pos 条目下标
+     * @return 左上角位置
+     */
+    private int[] getPageLeftTopByPosition(int pos) {
+        int[] leftTop = new int[2];
+        int page = getPageIndexByPos(pos);
+        if (canScrollHorizontally()) {
+            leftTop[0] = page * getUsableWidth();
+            leftTop[1] = 0;
+        } else {
+            leftTop[0] = 0;
+            leftTop[1] = page * getUsableHeight();
+        }
+        return leftTop;
+    }
+
+    /**
+     * 获取需要对齐的View
+     *
+     * @return 需要对齐的View
+     */
+    public View findSnapView() {
+        if (null != getFocusedChild()) {
+            return getFocusedChild();
+        }
+        if (getChildCount() <= 0) {
+            return null;
+        }
+        int targetPos = getPageIndexByOffset() * mOnePageSize;   // 目标Pos
+        for (int i = 0; i < getChildCount(); i++) {
+            int childPos = getPosition(getChildAt(i));
+            if (childPos == targetPos) {
+                return getChildAt(i);
+            }
+        }
+        return getChildAt(0);
+    }
+
+
+    //--- 处理页码变化 -------------------------------------------------------------------------------
+
+    private boolean mChangeSelectInScrolling = true;    // 是否在滚动过程中对页面变化回调
+    private int mLastPageCount = -1;                    // 上次页面总数
+    private int mLastPageIndex = -1;                    // 上次页面下标
+
+    /**
+     * 设置页面总数
+     *
+     * @param pageCount 页面总数
+     */
+    private void setPageCount(int pageCount) {
+        if (pageCount >= 0) {
+            if (mPageListener != null && pageCount != mLastPageCount) {
+                mPageListener.onPageSizeChanged(pageCount);
+            }
+            mLastPageCount = pageCount;
+        }
+    }
+
+    /**
+     * 设置当前选中页面
+     *
+     * @param pageIndex   页面下标
+     * @param isScrolling 是否处于滚动状态
+     */
+    private void setPageIndex(int pageIndex, boolean isScrolling) {
+        Loge("setPageIndex = " + pageIndex + ":" + isScrolling);
+        if (pageIndex == mLastPageIndex) return;
+        // 如果允许连续滚动,那么在滚动过程中就会更新页码记录
+        if (isAllowContinuousScroll()) {
+            mLastPageIndex = pageIndex;
+        } else {
+            // 否则,只有等滚动停下时才会更新页码记录
+            if (!isScrolling) {
+                mLastPageIndex = pageIndex;
+            }
+        }
+        if (isScrolling && !mChangeSelectInScrolling) return;
+        if (pageIndex >= 0) {
+            if (null != mPageListener) {
+                mPageListener.onPageSelect(pageIndex);
+            }
+        }
+    }
+
+    /**
+     * 设置是否在滚动状态更新选中页码
+     *
+     * @param changeSelectInScrolling true:更新、false:不更新
+     */
+    public void setChangeSelectInScrolling(boolean changeSelectInScrolling) {
+        mChangeSelectInScrolling = changeSelectInScrolling;
+    }
+
+    /**
+     * 设置滚动方向
+     *
+     * @param orientation 滚动方向
+     * @return 最终的滚动方向
+     */
+    @OrientationType
+    public int setOrientationType(@OrientationType int orientation) {
+        if (mOrientation == orientation || mScrollState != RecyclerView.SCROLL_STATE_IDLE) return mOrientation;
+        mOrientation = orientation;
+        mItemFrames.clear();
+        int x = mOffsetX;
+        int y = mOffsetY;
+        mOffsetX = y / getUsableHeight() * getUsableWidth();
+        mOffsetY = x / getUsableWidth() * getUsableHeight();
+        int mx = mMaxScrollX;
+        int my = mMaxScrollY;
+        mMaxScrollX = my / getUsableHeight() * getUsableWidth();
+        mMaxScrollY = mx / getUsableWidth() * getUsableHeight();
+        return mOrientation;
+    }
+
+    //--- 滚动到指定位置 -----------------------------------------------------------------------------
+
+    @Override
+    public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, int position) {
+        int targetPageIndex = getPageIndexByPos(position);
+        smoothScrollToPage(targetPageIndex);
+    }
+
+    /**
+     * 平滑滚动到上一页
+     */
+    public void smoothPrePage() {
+        smoothScrollToPage(getPageIndexByOffset() - 1);
+    }
+
+    /**
+     * 平滑滚动到下一页
+     */
+    public void smoothNextPage() {
+        smoothScrollToPage(getPageIndexByOffset() + 1);
+    }
+
+    /**
+     * 平滑滚动到指定页面
+     *
+     * @param pageIndex 页面下标
+     */
+    public void smoothScrollToPage(int pageIndex) {
+        if (pageIndex < 0 || pageIndex >= mLastPageCount) {
+            Log.e(TAG, "pageIndex is outOfIndex, must in [0, " + mLastPageCount + ").");
+            return;
+        }
+        if (null == mRecyclerView) {
+            Log.e(TAG, "RecyclerView Not Found!");
+            return;
+        }
+
+        // 如果滚动到页面之间距离过大,先直接滚动到目标页面到临近页面,在使用 smoothScroll 最终滚动到目标
+        // 否则在滚动距离很大时,会导致滚动耗费的时间非常长
+        int currentPageIndex = getPageIndexByOffset();
+        if (Math.abs(pageIndex - currentPageIndex) > 3) {
+            if (pageIndex > currentPageIndex) {
+                scrollToPage(pageIndex - 3);
+            } else if (pageIndex < currentPageIndex) {
+                scrollToPage(pageIndex + 3);
+            }
+        }
+
+        // 具体执行滚动
+        LinearSmoothScroller smoothScroller = new PagerGridSmoothScroller(mRecyclerView);
+        int position = pageIndex * mOnePageSize;
+        smoothScroller.setTargetPosition(position);
+        startSmoothScroll(smoothScroller);
+    }
+
+    //=== 直接滚动 ===
+
+    @Override
+    public void scrollToPosition(int position) {
+        int pageIndex = getPageIndexByPos(position);
+        scrollToPage(pageIndex);
+    }
+
+    /**
+     * 上一页
+     */
+    public void prePage() {
+        scrollToPage(getPageIndexByOffset() - 1);
+    }
+
+    /**
+     * 下一页
+     */
+    public void nextPage() {
+        scrollToPage(getPageIndexByOffset() + 1);
+    }
+
+    /**
+     * 滚动到指定页面
+     *
+     * @param pageIndex 页面下标
+     */
+    public void scrollToPage(int pageIndex) {
+        if (pageIndex < 0 || pageIndex >= mLastPageCount) {
+            Log.e(TAG, "pageIndex = " + pageIndex + " is out of bounds, mast in [0, " + mLastPageCount + ")");
+            return;
+        }
+
+        if (null == mRecyclerView) {
+            Log.e(TAG, "RecyclerView Not Found!");
+            return;
+        }
+
+        int mTargetOffsetXBy = 0;
+        int mTargetOffsetYBy = 0;
+        if (canScrollVertically()) {
+            mTargetOffsetXBy = 0;
+            mTargetOffsetYBy = pageIndex * getUsableHeight() - mOffsetY;
+        } else {
+            mTargetOffsetXBy = pageIndex * getUsableWidth() - mOffsetX;
+            mTargetOffsetYBy = 0;
+        }
+        Loge("mTargetOffsetXBy = " + mTargetOffsetXBy);
+        Loge("mTargetOffsetYBy = " + mTargetOffsetYBy);
+        mRecyclerView.scrollBy(mTargetOffsetXBy, mTargetOffsetYBy);
+        setPageIndex(pageIndex, false);
+    }
+
+    /**
+     * 是否允许连续滚动,默认为允许
+     *
+     * @return true 允许, false 不允许
+     */
+    public boolean isAllowContinuousScroll() {
+        return mAllowContinuousScroll;
+    }
+
+    /**
+     * 设置是否允许连续滚动
+     *
+     * @param allowContinuousScroll true 允许,false 不允许
+     */
+    public void setAllowContinuousScroll(boolean allowContinuousScroll) {
+        mAllowContinuousScroll = allowContinuousScroll;
+    }
+
+    //--- 对外接口 ----------------------------------------------------------------------------------
+
+    private PageListener mPageListener = null;
+
+    public void setPageListener(PageListener pageListener) {
+        mPageListener = pageListener;
+    }
+
+    public interface PageListener {
+        /**
+         * 页面总数量变化
+         *
+         * @param pageSize 页面总数
+         */
+        void onPageSizeChanged(int pageSize);
+
+        /**
+         * 页面被选中
+         *
+         * @param pageIndex 选中的页面
+         */
+        void onPageSelect(int pageIndex);
+    }
+}

+ 70 - 0
plugins/keyboard_android/android/src/main/kotlin/com/gcssloop/widget/PagerGridSmoothScroller.java

@@ -0,0 +1,70 @@
+/*
+ * Copyright 2018 GcsSloop
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * Last modified 2018-04-09 23:56:59
+ *
+ * GitHub: https://github.com/GcsSloop
+ * WeiBo: http://weibo.com/GcsSloop
+ * WebSite: http://www.gcssloop.com
+ */
+
+package com.gcssloop.widget;
+
+import static com.gcssloop.widget.PagerConfig.Logi;
+
+import android.util.DisplayMetrics;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.LinearSmoothScroller;
+import androidx.recyclerview.widget.RecyclerView;
+
+/**
+ * 作用:用于处理平滑滚动
+ * 作者:GcsSloop
+ * 摘要:用于用户手指抬起后页面对齐或者 Fling 事件。
+ */
+public class PagerGridSmoothScroller extends LinearSmoothScroller {
+    private final RecyclerView mRecyclerView;
+
+    public PagerGridSmoothScroller(@NonNull RecyclerView recyclerView) {
+        super(recyclerView.getContext());
+        mRecyclerView = recyclerView;
+    }
+
+    @Override
+    protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
+        RecyclerView.LayoutManager manager = mRecyclerView.getLayoutManager();
+        if (null == manager) return;
+        if (manager instanceof PagerGridLayoutManager) {
+            PagerGridLayoutManager layoutManager = (PagerGridLayoutManager) manager;
+            int pos = mRecyclerView.getChildAdapterPosition(targetView);
+            int[] snapDistances = layoutManager.getSnapOffset(pos);
+            final int dx = snapDistances[0];
+            final int dy = snapDistances[1];
+            Logi("dx = " + dx);
+            Logi("dy = " + dy);
+            final int time = calculateTimeForScrolling(Math.max(Math.abs(dx), Math.abs(dy)));
+            if (time > 0) {
+                action.update(dx, dy, time, mDecelerateInterpolator);
+            }
+        }
+    }
+
+    @Override
+    protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
+        return PagerConfig.getMillisecondsPreInch() / displayMetrics.densityDpi;
+    }
+}

+ 202 - 0
plugins/keyboard_android/android/src/main/kotlin/com/gcssloop/widget/PagerGridSnapHelper.java

@@ -0,0 +1,202 @@
+/*
+ * Copyright 2017 GcsSloop
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * Last modified 2017-09-20 16:32:43
+ *
+ * GitHub: https://github.com/GcsSloop
+ * WeiBo: http://weibo.com/GcsSloop
+ * WebSite: http://www.gcssloop.com
+ */
+
+package com.gcssloop.widget;
+
+import static com.gcssloop.widget.PagerConfig.Loge;
+
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.LinearSmoothScroller;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.recyclerview.widget.SnapHelper;
+
+/**
+ * 作用:分页居中工具
+ * 作者:GcsSloop
+ * 摘要:每次只滚动一个页面
+ */
+public class PagerGridSnapHelper extends SnapHelper {
+    private RecyclerView mRecyclerView;                     // RecyclerView
+
+    /**
+     * 用于将滚动工具和 Recycler 绑定
+     *
+     * @param recyclerView RecyclerView
+     * @throws IllegalStateException 状态异常
+     */
+    @Override
+    public void attachToRecyclerView(@Nullable RecyclerView recyclerView) throws
+            IllegalStateException {
+        super.attachToRecyclerView(recyclerView);
+        mRecyclerView = recyclerView;
+    }
+
+    /**
+     * 计算需要滚动的向量,用于页面自动回滚对齐
+     *
+     * @param layoutManager 布局管理器
+     * @param targetView    目标控件
+     * @return 需要滚动的距离
+     */
+    @Nullable
+    @Override
+    public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager,
+                                              @NonNull View targetView) {
+        int pos = layoutManager.getPosition(targetView);
+        Loge("findTargetSnapPosition, pos = " + pos);
+        int[] offset = new int[2];
+        if (layoutManager instanceof PagerGridLayoutManager) {
+            PagerGridLayoutManager manager = (PagerGridLayoutManager) layoutManager;
+            offset = manager.getSnapOffset(pos);
+        }
+        return offset;
+    }
+
+    /**
+     * 获得需要对齐的View,对于分页布局来说,就是页面第一个
+     *
+     * @param layoutManager 布局管理器
+     * @return 目标控件
+     */
+    @Nullable
+    @Override
+    public View findSnapView(RecyclerView.LayoutManager layoutManager) {
+        if (layoutManager instanceof PagerGridLayoutManager) {
+            PagerGridLayoutManager manager = (PagerGridLayoutManager) layoutManager;
+            return manager.findSnapView();
+        }
+        return null;
+    }
+
+    /**
+     * 获取目标控件的位置下标
+     * (获取滚动后第一个View的下标)
+     *
+     * @param layoutManager 布局管理器
+     * @param velocityX     X 轴滚动速率
+     * @param velocityY     Y 轴滚动速率
+     * @return 目标控件的下标
+     */
+    @Override
+    public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager,
+                                      int velocityX, int velocityY) {
+        int target = RecyclerView.NO_POSITION;
+        Loge("findTargetSnapPosition, velocityX = " + velocityX + ", velocityY" + velocityY);
+        if (layoutManager instanceof PagerGridLayoutManager) {
+            PagerGridLayoutManager manager = (PagerGridLayoutManager) layoutManager;
+            if (manager.canScrollHorizontally()) {
+                if (velocityX > PagerConfig.getFlingThreshold()) {
+                    target = manager.findNextPageFirstPos();
+                } else if (velocityX < -PagerConfig.getFlingThreshold()) {
+                    target = manager.findPrePageFirstPos();
+                }
+            } else if (manager.canScrollVertically()) {
+                if (velocityY > PagerConfig.getFlingThreshold()) {
+                    target = manager.findNextPageFirstPos();
+                } else if (velocityY < -PagerConfig.getFlingThreshold()) {
+                    target = manager.findPrePageFirstPos();
+                }
+            }
+        }
+        Loge("findTargetSnapPosition, target = " + target);
+        return target;
+    }
+
+    /**
+     * 一扔(快速滚动)
+     *
+     * @param velocityX X 轴滚动速率
+     * @param velocityY Y 轴滚动速率
+     * @return 是否消费该事件
+     */
+    @Override
+    public boolean onFling(int velocityX, int velocityY) {
+        RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
+        if (layoutManager == null) {
+            return false;
+        }
+        RecyclerView.Adapter adapter = mRecyclerView.getAdapter();
+        if (adapter == null) {
+            return false;
+        }
+        int minFlingVelocity = PagerConfig.getFlingThreshold();
+        Loge("minFlingVelocity = " + minFlingVelocity);
+        return (Math.abs(velocityY) > minFlingVelocity || Math.abs(velocityX) > minFlingVelocity)
+                && snapFromFling(layoutManager, velocityX, velocityY);
+    }
+
+    /**
+     * 快速滚动的具体处理方案
+     *
+     * @param layoutManager 布局管理器
+     * @param velocityX     X 轴滚动速率
+     * @param velocityY     Y 轴滚动速率
+     * @return 是否消费该事件
+     */
+    private boolean snapFromFling(@NonNull RecyclerView.LayoutManager layoutManager, int velocityX,
+                                  int velocityY) {
+        if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
+            return false;
+        }
+
+        RecyclerView.SmoothScroller smoothScroller = createSnapScroller(layoutManager);
+        if (smoothScroller == null) {
+            return false;
+        }
+
+        int targetPosition = findTargetSnapPosition(layoutManager, velocityX, velocityY);
+        if (targetPosition == RecyclerView.NO_POSITION) {
+            return false;
+        }
+
+        smoothScroller.setTargetPosition(targetPosition);
+        layoutManager.startSmoothScroll(smoothScroller);
+        return true;
+    }
+
+    /**
+     * 通过自定义 LinearSmoothScroller 来控制速度
+     *
+     * @param layoutManager 布局故哪里去
+     * @return 自定义 LinearSmoothScroller
+     */
+    protected LinearSmoothScroller createSnapScroller(RecyclerView.LayoutManager layoutManager) {
+        if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
+            return null;
+        }
+        return new PagerGridSmoothScroller(mRecyclerView);
+    }
+
+    //--- 公开方法 ----------------------------------------------------------------------------------
+
+    /**
+     * 设置滚动阀值
+     *
+     * @param threshold 滚动阀值
+     */
+    public void setFlingThreshold(int threshold) {
+        PagerConfig.setFlingThreshold(threshold);
+    }
+}

+ 1 - 1
plugins/keyboard_android/android/src/main/res/layout/item_ai_keyboard_key.xml

@@ -7,7 +7,7 @@
 
     <TextView
         android:id="@+id/key_text"
-        android:layout_width="88dp"
+        android:layout_width="match_parent"
         android:layout_height="46dp"
         android:layout_gravity="center"
         android:background="@drawable/bg_ai_keyboard_key"