Android:实现类似QQ、微信的表情输入键盘

终于有时间继续写博客了,上一篇博客中提到的使用「adjustPan」模式来实现表情输入键盘的思路,在这一段时间的使用过程中出现了很多兼容性问题,各种机型(有无虚拟按键)、系统版本(高度返回值是否包含状态栏)返回的软键盘高度与实际的软键盘高度不同,监听的调用方式也不尽相同,最后导致界面出现错位。起始回头仔细想想,让软键盘覆盖表情键盘这种办法从代码角度看确实很傻。。。不够优雅。

研究了一段时间、翻看了一些开源项目的实现原理,特别是著名的「四次元」,给了我很大启发,我重写了一遍依赖库,给北邮人论坛客户端更新后,再没有人反馈表情键盘有问题了,下面简单谈谈实现的思路。

demo

SoftInputMode 模式

首先,Android系统在界面上弹出软键盘时会将整个Activity的高度压缩,即默认的SoftInputMode是「AdjustResize」,直觉上表情键盘的高度应该设置得和软键盘相同,显示表情键盘时的同时将软键盘收起。这种思路比较直观,比「AdjustPan」模式好的地方在于表情键盘只有两种状态(Visible/Gone),而不是三种(Visible/Invisible/Gone),处理起来逻辑上会更简单。

表情键盘的高度

如果按照上面的思路直接写,应该是这样:

emotionButton.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        if (mEmotionLayout.isShown()) {
            hideEmotionLayout();
        } else {
            showEmotionLayout();
        }
    }
});

代码很简单,几乎没有别的逻辑判断,同样的,我们在显示表情键盘之前,需要判断一下软键盘的高度,将其设置给表情键盘:

private void showEmotionLayout() {
    int softInputHeight = getSupportSoftInputHeight();
    if (softInputHeight == 0) {
        softInputHeight = sp.getInt(SHARE_PREFERENCE_TAG, 400);
    }
    hideSoftInput();
    mEmotionLayout.getLayoutParams().height = softInputHeight;
    mEmotionLayout.setVisibility(View.VISIBLE);
}

private void hideEmotionLayout(boolean showSoftInput) {
    if (mEmotionLayout.isShown()) {
        mEmotionLayout.setVisibility(View.GONE);
        if (showSoftInput) {
            showSoftInput();
        }
    }
}

界面跳动

但是运行起来会发现一个问题,点击按钮打开表情键盘时,输入框会上下跳动一下。分析一下原因,点击按钮后,先收起了软键盘,当前Activity的高度变高,输入框回到了界面底部;再打开表情键盘时,输入框又被顶上来,所有看起来点击按钮后输入框会上下跳动一下。无论是先隐藏软键盘还是先打开表情键盘都会有这个问题。

如果这时候去纠结隐藏软键盘和打开表情键盘如何同步的话就会走进一个牛角尖,去处理不同机型之间的兼容性问题了。其实解决思路非常简单,输入框不是会上下跳么,那固定它的位置不就好了?

举个例子,如果整个界面的根布局是LinearLayout,那么一个控件的位置其实是由它上面所有控件的高度决定的,如果它上面所有控件的高度都不变化,那即使整个Activity的高度变化(开/关软键盘)也不会影响这个控件的位置,也就不会发生跳动了。

我们假设有这样两个方法lockContentHeight()unlockContentHeight(),用来锁定和解锁输入框上面的所有控件的高度,那么点击按钮的监听就应该这样写:

@Override
public void onClick(View v) {
    if (mEmotionLayout.isShown()) {
        lockContentHeight();
        hideEmotionLayout(true);
        unlockContentHeight();
    } else {
        if (isSoftInputShown()) {
            lockContentHeight();
            showEmotionLayout();
            unlockContentHeight();
        } else {
            showEmotionLayout();
        }
    }
}

为了更好的表现形式,这里的unlockContentHeight()可以替换成unlockContentHeightDelayed(),即延迟一会(例如200ms)再解锁高度,留出播放软键盘收回动画的时间。

锁定和解锁高度

现在整个问题的关键就在于锁定和解锁控件的高度了,那么如何实现lockContentHeight()unlockContentHeight()这两个函数呢?我们仍以根布局是LinearLayout为例,一个典型的界面布局如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >

    <ListView
        android:id="@+id/list"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        />

    <include
        layout="@layout/reply_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

</LinearLayout>

其中ListView的layout_height为0dp、layout_weight为1,这样这个ListView就会自动充满整个布局,这里ListView可以替换成任意控件(比如一个RelativeLayout,内部有更复杂的布局)。

当你需要锁定这个ListView的高度时:

private void lockContentHeight() {
    LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) mContentView.getLayoutParams();
    params.height = mContentView.getHeight();
    params.weight = 0.0F;
}

将weight置0,然后将height设置为当前的height,在父控件(LinearLayout)的高度变化时它的高度也不再会变化。而解锁高度时这样做:

private void unlockContentHeightDelayed() {
    mEditText.postDelayed(new Runnable() {
        @Override
        public void run() {
            ((LinearLayout.LayoutParams) mContentView.getLayoutParams()).weight = 1.0F;
        }
    }, 200L);
}

在上面的函数中解锁高度其实只有一句话:LinearLayout.LayoutParams.weight = 1.0F;,在Java代码里动态更改LayoutParam的weight,会导致父控件重新onLayout(),从而达到改变控件的高度的目的。

总结

整体思路基本上就是:

  1. 点击表情按钮
  2. 锁定内容高度
  3. 收起软键盘
  4. 显示表情键盘
  5. 解锁内容高度

是不是比上一篇博客中分析三种显示状态,监听软键盘变化而做同步变化的方法要简单明了很多?

详细代码见我的Github:dss886/Android-EmotionInputDetector

Android:表情输入键盘实现探索

本篇文章思路最终的实现效果还不是很完美,更好的实现方法见Android: 类似QQ、微信的表情输入键盘实现思路一文。

需求

最近在写北邮人论坛客户端时,有一个需求是实现像手机QQ、微信那样的表情输入键盘,效果图:

demo

表情键盘本身并不难做,无非就是一个带SlidingTab的ViewPager,困扰我的地方在于,如何正确处理系统软键盘与表情键盘之间的显隐关系。

Google了一下,大概有这么几种思路:

第一种:动态改变SoftInputMode

这篇博文是国内网上转载比较多的方法,软键盘显示时将SoftInputMode设置为「stateVisible|adjustResize」,表情键盘显示时调整为「adjustPan」。

但在我实际使用过程中效果并不理想,一是我需要在一个ListView的底部实现表情键盘,这样动态更改SoftInputMode会导致ListView上下跳动;二是切换到别的界面再切换回来时软键盘的显隐状态偶尔会有冲突,最终我放弃了这种方法。

第二种:Dialog

Emoticons-Keyboard这个项目的实现方法是直接在软键盘上覆盖显示一个Dialog,避开了大部分的显示逻辑操作,思路非常独特,可惜我编译运行后发现显示效果并不好,除了动画效果,最大的问题仍然是是从别的界面切换过来时,与软键盘的显示有冲突

基本思路

上面提到的两个项目给了我很大的启发,我反复尝试了微信、微博、手机QQ等应用的表情键盘逻辑,发现它们切换键盘并不会导致ListView跳动,如果没有别的什么黑科技的话,基本可以断定使用的SoftInputMode就是adjustPan。(SoftInputMode各个属性值的意义

既然是adjustPan就好说了,软键盘显示的时候不会导致ListView跳动,那么Activity的底部必然有一个跟软键盘相同高度的View被软键盘覆盖了,这个View其实就是表情输入键盘,这样点击表情按钮的时候只需要显示隐藏软键盘,背后的表情框就显示出来了。

思路有了,接下来就是梳理一下所需要的技术点:

  • 如何检测软键盘高度(用于动态设置表情键盘的高度)?
  • 在代码中如何手动显示/隐藏软键盘?
  • 如何防止从别的界面切换过来时,软键盘状态改变了有可能导致的显示冲突?

如果这三个问题解决了,需求就基本实现了。

检测软键盘的高度

直接上代码:

private int getSupportSoftInputHeight() {
    Rect r = new Rect();
    mActivity.getWindow().getDecorView().getWindowVisibleDisplayFrame(r);
    int screenHeight = mContext.getWindow().getDecorView().getRootView().getHeight();
    int softInputHeight = screenHeight - r.bottom;
    if (Build.VERSION.SDK_INT >= 20) {
        // When SDK Level >= 20 (Android L), the softInputHeight will contain the height of softButtonsBar (if has)
        softInputHeight = softInputHeight - getSoftButtonsBarHeight();
    }
    return softInputHeight;
}

这里的原理是通过当前的Activity拿到其RootView的高度,减去Activity本身实际的高度,就等于软键盘的高度了。但在实际应用过程中发现,某些Android版本下,没有显示软键盘时减出来的高度总是144,而不是零,经过反复研究,最后发现这个高度是包括了虚拟按键栏的,所以在API Level高于18时,我们需要减去底部虚拟按键栏的高度(如果有的话)。

@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
private int getSoftButtonsBarHeight() {
    DisplayMetrics metrics = new DisplayMetrics();
    mContext.getWindowManager().getDefaultDisplay().getMetrics(metrics);
    int usableHeight = metrics.heightPixels;
    mContext.getWindowManager().getDefaultDisplay().getRealMetrics(metrics);
    int realHeight = metrics.heightPixels;
    if (realHeight > usableHeight) {
        return realHeight - usableHeight;
    } else {
        return 0;
    }
}

将高度设置给表情键盘就比较简单了:

LinearLayout.LayoutParams linearParams = (LinearLayout.LayoutParams) mEmotionLayout.getLayoutParams();
linearParams.height = getSupportSoftInputHeight();

在代码中手动显示、隐藏软键盘

也是直接上代码了,这两个方法也比较容易查到:

private void showSoftInput() {
    mInputManager.showSoftInput(mEditText, 0);
}

private void hideSoftInput() {
    mInputManager.hideSoftInputFromWindow(mEditText.getWindowToken(), 0);
}

解决切换程序时的显示冲突

在默认状态(StateUnspecified)下,在程序内打开软键盘然后点击Home键或多任务键切换出去时,软键盘会收起。再次进入程序界面也不会打开,前文提到的两个项目就是在这种情况下会出现问题。如何保证软键盘和表情键盘的同步,直观反应就是监听软键盘的高度变化,查了一下,果然可以监听:

mEditText.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
    @Override
    public void onGlobalLayout() {
        int softInputHeight = getSupportSoftInputHeight();
        if (softInputHeight != lastSoftInputHeight) {
            // do Something
        }
    }
});

实际测试中,这个函数在运行时会调用很多次,我们只需要在高度变化时做处理即可。

states

如上图,一共有三种状态,表情键盘的状态分别为:gone、invisible和visible。分别判断这三个状态之间的转化关系,然后动态的设置Visiblity即可:

public void onGlobalLayout() {
    int softInputHeight = getSupportSoftInputHeight();
    if (softInputHeight != lastSoftInputHeight) {
        if (softInputHeight <= 0) {
            lastSoftInputHeight = softInputHeight;
            if (!notHideEmojiLayout) {
                mEmotionLayout.setVisibility(View.GONE);
            } else {
                notHideEmojiLayout = false;
            }
        } else {
            lastSoftInputHeight = softInputHeight;
            LinearLayout.LayoutParams linearParams = (LinearLayout.LayoutParams) mEmotionLayout.getLayoutParams();
            linearParams.height = softInputHeight;
            mEmotionLayout.setVisibility(View.INVISIBLE);
            if (linearParams.height == softInputHeight) {
                mEmotionLayout.setVisibility(View.INVISIBLE);
            } else {
                linearParams.height = softInputHeight;
            }

            sp.edit().putInt(SHARE_PREFERENCE_TAG, softInputHeight).apply();
        }
    }
}

一点小Bug

由于Android设备的多样性,软键盘高度不一致,所以需要动态的设置表情键盘的高度,然而程序在第一次软键盘弹出后才能检测到软键盘高度,但这时由于表情键盘高度与软键盘不一致,会导致显示有点异常。所以程序会将检测到的高度保存到SharedPreference中,在Activity加载时读出高度即可。

不过即使是这样,在整个程序第一次进入这个界面时还是会显示异常,暂时的解决办法是在其他软键盘弹出的页面检测一次软键盘高度

如果你有更好的办法,请留言交流~

使用JitPack发布你的Github开源库

最近从手头的一个项目中独立出一个Android的开源组件(EmotionLayoutDetector)发布到了Github上,想用Gradle来管理依赖。

一般来说,想在Android Studio使用Gradle依赖,有几种方法:

  • jCenter
  • Maven Central
  • 使用其他自定义的仓库

使用jCenter和MavenCentral的一般流程是,注册、登陆、配置参数、Build、Push、等待验证,最后才能通过,整个网站有一种上世纪的感觉,发布过程繁杂冗长,让我觉得十分不舒服。

在使用别的开源组件的过程中发现,越来越多的Github项目开始使用JitPack替代jCenter进行发布

JitPack实际上是一个自定义的Maven仓库,不过它的流程极度简化,只需要输入Github项目地址就可发布项目,大大方便了像我这种懒得配置环境的人。

发布

在网站首页输入你的Github项目地址,点击「Look up」就可发布

image1

使用

在配置中加入自定义的仓库

allprojects {
    repositories {
        jcenter()
        maven { url "https://jitpack.io" }
    }
}

加入依赖

dependencies {
    compile 'com.github.username:Project-name:v1.0'
}

这样就行了,是不是很简单?

Android: Activity在Restore时的数据缓存问题

红米Note上频繁NullPointException

继解决了Fragment中使用getActivity()返回null的问题后,在测试中又发现,在红米Note上离开程序后从后台返回时经常Crash,错误仍然是NullPointException。。。

项目需求是要求先登录,在LoginActivity登录完毕获取到Token后,我直接用Intent传给了主界面MainActivity再进行后续操作。但是当程序后台被杀掉后再恢复时,虽然会重新执行MainActivity的onCreate(),但是Intent内的数据却不会再有,我取出Token后没有做判断就使用,于是就抛出了NullPointException。

红米系列内存比较小,App切换到后台以后极易被干掉,所以这个问题在红米Note上比较容易重现。

保存数据

错误的原因在于没有缓存Intent传入的数据,那我们就缓存一下好了。

最简单的办法莫过于写入SharedPreference了,存入取出都很方便:

SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this);
// 存入数据
sp.edit().putString("token", token).apply();
// 取出数据
String token = sp.getString("token", null);

但是sp只适合存放少量的数据,若需要缓存的数据稍复杂一点用sp就会很麻烦,另一种办法是用数据库缓存,但数据库缓存代码量比较大,改动也不是很方便。

其实,Android已经考虑到了这种问题,提供了一种系统级的界面缓存数据的方法,即InstanceState机制。

利用Android的InstanceState机制

我们可以看到,在Activity的onCreate()方法是带参数的:

@Override
protected void onCreate(Bundle savedInstanceState) {
	super.onCreate(savedInstanceState);
}

同时,Activity还带有两个方法:onSaveInstanceState()onRestoreInstanceState():

Activity的 onSaveInstanceState() 和 onRestoreInstanceState()并不是生命周期方法。

它们不同于 onCreate()、onPause()等生命周期方法,它们并不一定会被触发。当应用遇到意外情况(如:内存不足、用户直接按Home键)由系统销毁一个Activity时,onSaveInstanceState()会被调用。

但是当用户主动去销毁一个Activity时,例如在应用中按返回键,onSaveInstanceState()就不会被调用。因为在这种情况下,用户的行为决定了不需要保存Activity的状态。

通常onSaveInstanceState()只适合用于保存一些临时性的状态,而onPause()适合用于数据的持久化保存。

因此,在InstanceState内保存数据的方法类似SP,非常简单:

@Override
public void onSaveInstanceState(Bundle savedInstanceState){
    super.onSaveInstanceState(savedInstanceState);
    savedInstanceState.putString("token", token);
}

@Override
public void onRestoreInstanceState(Bundle savedInstanceState){
    super.onRestoreInstanceState(savedInstanceState);
    token = savedInstanceState.getString("token");
}

@Override
protected void onCreate(Bundle savedInstanceState) {
	super.onCreate(savedInstanceState);
	// 你也可以在onCreate()里恢复数据
	// 但这样做需要判断恢复的数据出来是否为null
	token = savedInstanceState.getString("token");
}

这样,在后台内存不足程序被回收时,会自动缓存数据,下次进入时自动恢复就不会抛出异常了。

参考

  1. Android开发之InstanceState详解
Android: Fragment中getActivity()返回null的问题

getActivity()返回null

在一个项目中使用了ViewPager+Fragment的组合,但是在实际使用中频繁的Crash。排查后发现,我在Fragment内有一些AsyncTask联网操作,在网络链接失败的时候会弹出Toast消息提示。而生成Toast时传入的Context参数是getActivity(),该函数返回null,于是就抛出了NullPointException:

Toast.makeText(getActivity(), "Some Message", Toast.LENGTH_LONG).show();

查阅一下Fragment的生命周期:

image1

在Fragment的生命周期中,onAttach()onDetach()之间getActivity()函数才会返回正确的对象,否则的话返回null。

因此,如果我正在做某些操作联网,在等待过程中点击Back键返回,使得这个Fragment被销毁了,这时Fragment就会和Activity解除附着(onDetach),当再试图弹出Toast的时候,getActivity()返回null,于是就Crash了。

保存Context引用

明白了问题出在哪就好解决了,常用的方法是在Fragment附着在Activity上时用一个变量保存引用,即:

@Override
public void onAttach(Activity activity){
	this.mContext = activity;
}

使用全局Application来得到Context

在每个Fragment内都用一个变量保存Context的引用确实可以满足需求,但是代码冗余了不少,更一劳永逸的办法就是使用全局Application来得到Context。

Android程序中Application、Service和Activity都实现了Context,但只有Application才能保证在程序运行期间一直存在并且具有唯一性,因此在程序中可以使用Application来获得Context而不用担心空指针。

首先在manifest文件中注册Application

<application
	android:name=".MyApplication"
	android:icon="@drawable/ic_launcher"
	android:label="@string/app_name" >

然后创建MyApplication.java,我们在这里使用单例模式来对外保持Application的引用

public class MyApplication extends Application {
	private static MyApplication instance;

	@Override
	public void onCreate() {
		super.onCreate();
		instance = this;
	}

	public static MyApplication getInstance(){
		// 这里不用判断instance是否为空
		return instance;
	}
}

这样在程序的任何地方都可以使用Application来得到Context了。

Context context = MyApplication.getInstance();
Toast.makeText(context, "Your Toast Message", Toast.SHORT_TOAST).show();

参考

  1. Android Fragment 生命周期
  2. Android应用程序的Activity启动过程简要介绍