이미지 키보드 지원

그림 1. 이미지 키보드 지원의 예

사용자가 이모티콘, 스티커, 각종 리치 콘텐츠를 사용하여 의사소통하려 하는 경우가 많습니다. 이전 버전의 Android에서는 소프트 키보드(입력 방식 편집기(IME)라고도 함)를 통해 유니코드 이모티콘만 앱에 전송할 수 있었습니다. 리치 콘텐츠의 경우 앱은 다른 앱에서 사용할 수 없는 앱별 API를 빌드하거나 쉬운 공유 작업 또는 클립보드를 통한 이미지 전송과 같은 해결 방법을 사용해야 했습니다.

Android 7.1(API 수준 25)에서 Android SDK에는 Commit Content API가 포함됩니다. 이 API는 IME에서 이미지 및 리치 콘텐츠를 앱의 텍스트 편집기로 직접 전송하는 보편적 방법을 제공하며 버전 25.0.0부터 v13 지원 라이브러리에서도 사용할 수 있습니다. 지원 라이브러리는 Android 3.2(API 수준 13)부터 기기에서 실행되고 구현을 간소화하는 도우미 메서드가 포함되어 있으므로 이 라이브러리를 사용하는 것이 좋습니다.

이 API를 사용하면 모든 앱에 리치 콘텐츠를 전송할 수 있는 키보드는 물론 모든 키보드에서 리치 콘텐츠 입력을 허용하는 메시지 앱을 빌드할 수 있습니다. Google 키보드 및 앱(예: Google Messenger)은 Android 7.1의 Commit Content API를 지원합니다(그림 1 참고).

이 페이지에서는 IME 및 앱 모두에서 Commit Content API를 구현하는 방법을 보여줍니다.

사용 방법

키보드 이미지 삽입에는 IME와 앱이 모두 참여해야 합니다. 다음 시퀀스에서는 이미지 삽입 프로세스의 각 단계를 설명합니다.

  1. 사용자가 EditText를 탭하면 편집기는 EditorInfo.contentMimeTypes에서 허용하는 MIME 콘텐츠 유형 목록을 전송합니다.

  2. IME는 지원되는 유형 목록을 읽고 편집기가 허용할 수 있는 소프트 키보드의 콘텐츠를 표시합니다.

  3. 사용자가 이미지를 선택하면 IME는 commitContent()를 호출하고 InputContentInfo를 편집기로 전송합니다. commitContent() 호출은 commitText() 호출과 비슷하지만 리치 콘텐츠용입니다. InputContentInfo에는 콘텐츠 제공업체의 콘텐츠를 식별하는 URI가 포함됩니다.

앱에 이미지 지원 추가

IME에서 리치 콘텐츠를 허용하려면 앱이 허용되는 콘텐츠 유형을 IME에 알리고 해당 콘텐츠가 수신될 때 실행할 콜백 메서드를 지정해야 합니다. 다음 예에서는 PNG 이미지를 허용하는 EditText를 만드는 방법을 보여줍니다.

Kotlin

val editText = object : EditText(this) {

    override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
        val ic: InputConnection = super.onCreateInputConnection(editorInfo)
        EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/png"))

        val callback =
                InputConnectionCompat.OnCommitContentListener { inputContentInfo, flags, opts ->
                    val lacksPermission = (flags and
                            InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0
                    // read and display inputContentInfo asynchronously
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && lacksPermission) {
                        try {
                            inputContentInfo.requestPermission()
                        } catch (e: Exception) {
                            return@OnCommitContentListener false // return false if failed
                        }
                    }

                    // read and display inputContentInfo asynchronously.
                    // call inputContentInfo.releasePermission() as needed.

                    true  // return true if succeeded
                }
        return InputConnectionCompat.createWrapper(ic, editorInfo, callback)
    }
}

자바

EditText editText = new EditText(this) {
    @Override
    public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
        final InputConnection ic = super.onCreateInputConnection(editorInfo);
        EditorInfoCompat.setContentMimeTypes(editorInfo,
                new String [] {"image/png"});

        final InputConnectionCompat.OnCommitContentListener callback =
            new InputConnectionCompat.OnCommitContentListener() {
                @Override
                public boolean onCommitContent(InputContentInfoCompat inputContentInfo,
                        int flags, Bundle opts) {
                    // read and display inputContentInfo asynchronously
                    if (BuildCompat.isAtLeastNMR1() && (flags &
                        InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
                        try {
                            inputContentInfo.requestPermission();
                        }
                        catch (Exception e) {
                            return false; // return false if failed
                        }
                    }

                    // read and display inputContentInfo asynchronously.
                    // call inputContentInfo.releasePermission() as needed.

                    return true;  // return true if succeeded
                }
            };
        return InputConnectionCompat.createWrapper(ic, editorInfo, callback);
    }
};

상당히 복잡하므로 잘 설명해 드리겠습니다.

권장사항은 다음과 같습니다.

  • 리치 콘텐츠를 지원하지 않는 편집기는 setContentMimeTypes()를 호출하고 그 EditorInfo.contentMimeTypesnull로 설정하면 안 됩니다.

  • 편집기는 InputContentInfo에서 지정된 MIME 유형이 콘텐츠가 허용하는 유형과 일치하지 않으면 콘텐츠를 무시해야 합니다.

  • 리치 콘텐츠는 텍스트 커서의 위치에 영향을 주지도 영향을 받지도 않습니다. 편집기는 콘텐츠를 사용할 때 커서 위치를 무시할 수 있습니다.

  • 편집기의 OnCommitContentListener.onCommitContent() 메서드에서 콘텐츠를 로드하기 전에도 true를 비동기적으로 반환할 수 있습니다.

  • 커밋되기 전에 IME에서 편집할 수 있는 텍스트와 달리 리치 콘텐츠는 즉시 커밋됩니다. 사용자에게 콘텐츠를 수정 또는 삭제하는 기능을 제공하려면 로직을 직접 구현해야 합니다.

앱을 테스트하려면 기기나 에뮬레이터에 리치 콘텐츠를 전송할 수 있는 키보드가 있는지 확인하세요. Android 7.1 이상에서 Google 키보드를 사용할 수 있습니다.

IME에 이미지 지원 추가

앱에 리치 콘텐츠를 전송하려는 IME는 다음과 같이 Commit Content API를 구현해야 합니다.

  • onStartInput() 또는 onStartInputView()를 재정의하고 타겟 편집기에서 지원되는 콘텐츠 유형 목록을 읽습니다. 다음 코드 스니펫은 타겟 편집기에서 GIF 이미지를 허용하는지 확인하는 방법을 보여줍니다.

Kotlin

override fun onStartInputView(editorInfo: EditorInfo, restarting: Boolean) {
    val mimeTypes: Array<String> = EditorInfoCompat.getContentMimeTypes(editorInfo)

    val gifSupported: Boolean = mimeTypes.any {
        ClipDescription.compareMimeTypes(it, "image/gif")
    }

    if (gifSupported) {
        // the target editor supports GIFs. enable corresponding content
    } else {
        // the target editor does not support GIFs. disable corresponding content
    }
}

자바

@Override
public void onStartInputView(EditorInfo info, boolean restarting) {
    String[] mimeTypes = EditorInfoCompat.getContentMimeTypes(editorInfo);

    boolean gifSupported = false;
    for (String mimeType : mimeTypes) {
        if (ClipDescription.compareMimeTypes(mimeType, "image/gif")) {
            gifSupported = true;
        }
    }

    if (gifSupported) {
        // the target editor supports GIFs. enable corresponding content
    } else {
        // the target editor does not support GIFs. disable corresponding content
    }
}

  • 사용자가 이미지를 선택하면 앱에 콘텐츠를 커밋합니다. 작성 중인 텍스트가 있으면 commitContent()를 호출하지 마세요. 편집기가 포커스를 잃을 수도 있습니다. 다음 코드 스니펫은 GIF 이미지를 커밋하는 방법을 보여줍니다.

Kotlin

/**
 * Commits a GIF image
 *
 * @param contentUri Content URI of the GIF image to be sent
 * @param imageDescription Description of the GIF image to be sent
 */
fun commitGifImage(contentUri: Uri, imageDescription: String) {
    val inputContentInfo = InputContentInfoCompat(
            contentUri,
            ClipDescription(imageDescription, arrayOf("image/gif")),
            null
    )
    val inputConnection = currentInputConnection
    val editorInfo = currentInputEditorInfo
    var flags = 0
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
        flags = flags or InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION
    }
    InputConnectionCompat.commitContent(inputConnection, editorInfo, inputContentInfo, flags, null)
}

자바

/**
 * Commits a GIF image
 *
 * @param contentUri Content URI of the GIF image to be sent
 * @param imageDescription Description of the GIF image to be sent
 */
public static void commitGifImage(Uri contentUri, String imageDescription) {
    InputContentInfoCompat inputContentInfo = new InputContentInfoCompat(
            contentUri,
            new ClipDescription(imageDescription, new String[]{"image/gif"}),
            null
    );
    InputConnection inputConnection = getCurrentInputConnection();
    EditorInfo editorInfo = getCurrentInputEditorInfo();
    Int flags = 0;
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
        flags |= InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION;
    }
    InputConnectionCompat.commitContent(
            inputConnection, editorInfo, inputContentInfo, flags, null);
}

  • IME 작성자로서 개발자는 자체 콘텐츠 제공자를 구현하여 콘텐츠 URI 요청에 응답해야 합니다. IME가 MediaStore와 같은 기존 콘텐츠 제공업체의 콘텐츠를 지원하는 경우는 예외입니다. 콘텐츠 제공자 빌드에 관한 자세한 내용은 콘텐츠 제공자파일 제공자 문서를 참고하세요.

  • 자체 콘텐츠 제공자를 빌드한다면 내보내지 않는 것이 좋습니다(android:exportedfalse로 설정). 대신 android:grantUriPermissiontrue로 설정하여 제공자에서 권한 부여를 사용 설정합니다. 그러면 콘텐츠가 커밋될 때 IME에서 콘텐츠 URI 액세스 권한을 부여할 수 있습니다. 여기에는 두 가지 방법이 있습니다.

IME를 테스트하려면 기기나 에뮬레이터에 리치 콘텐츠를 수신할 수 있는 앱이 있는지 확인하세요. Android 7.1 이상에서 Google Messenger 앱을 사용할 수 있습니다.