Tempo Di Valse

[Android] 잘라내기, 복사, 붙여넣기 기능을 후킹 해보자 (ActionMode.Callback) 본문

개발/Android

[Android] 잘라내기, 복사, 붙여넣기 기능을 후킹 해보자 (ActionMode.Callback)

TempoDiValse 2025. 8. 11. 10:54

정말 오랜만에 작성하는 포스팅이다.

 

이직한 이후로 많이 게을렀고, 기록할 만한 내용들도 거의 없는 사무적인 루틴이다보니 작성을 계속 미뤘는데 오랜만에 하나 적어본다.


이번 주제는 ActionMode.Callback 를 통해 "잘라내기", "복사", "붙여넣기" 기능을 커스터마이즈 하거나, "메뉴 추가" 그리고 기타 내용에 대해서 알아보려고 한다.

 

ActionMode 란 다음 문서에서는 자세하게 나와있다.

 

메뉴 추가  |  Views  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 메뉴 추가 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Compose 사용해 보기 Jetpack Compose는 Android에 권

developer.android.com

그러나 설명이 기니까 짧게 얘기한다면, ActionMode 라는 것은 사용자가 취한 행동 (텍스트 롱클릭을 통한 컨텍스트 메뉴 호출이라던가 더보기 버튼을 눌러 호출한 드롭다운 컨텍스트 메뉴 호출이라던가....) 에 대한 거라고 생각하면 쉬울 것 같고, ActionMode 를 통해 상호작용되는 행동들은 ActionMode.Callback 을 통해 컨텍스트 메뉴의 [생성, 준비, 클릭 이벤트, 소멸] 을 콜백 리스너를 통하여 신호를 받을 수 있는 기능이라고 하면 될 것 같다.

 

여기 다음의 View 가 있다.

롱클릭을 하게되면 컨텍스트 메뉴가 활성화된다.

 

기본적으로 EditText 는 롱클릭을 하게 되면 컨텍스트 메뉴를 지원해주는데, 기본 기능에는 [잘라내기, 복사, 붙여넣기] 가 있다.

 

여기서 ActionMode.Callback 을 어떻게 적용하고 다룰 수 있냐... 하면,

 

1. [잘라내기, 복사, 붙여넣기] 기능을 후킹해보자.

기본 기능인 이 3개의 기능을, 어떤 요구사항에서는 "'복사' 나 '잘라내기' 를 할 때, 어떤 형식으로 복사가 되도록 처리하라!" 라는 거나, "붙여넣기 할 때에는 문자열을 한번 훑어서 텍스트를 붙여넣어라!" 라는 일이 생길 수 있다. 그럴 때에는 다음과 같이 적용할 수 있다.

 

ActionMode.Callback 은 Interface 이다. 그래서 다음의 메소드를 구현해야 하는데,

class ActionModeCallback: ActionMode.Callback {
    // 아이템의 클릭 이뤄질 때
    override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean = false

    // 컨텍스트 메뉴가 생성될 때
    override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean = true

    // 컨텍스트 메뉴가 종료될 때
    override fun onDestroyActionMode(mode: ActionMode?) = Unit

    // 컨텍스트 메뉴가 갱신될 때
    override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean = false
}

 

여기서 onActionItemClicked 를 사용할 수 있다. 컨텍스트 메뉴 버튼을 클릭하게 되면 onActionItemClicked 가 호출되며, 접근은 item 파라미터로 할 수 있다. item 객체에서 기능 구분은 itemId 를 통해 가능한데, 각 메뉴의 ID 는 다음 값으로 구분하기 때문에 when 으로 처리할 수 있다.

- 잘라내기 : android.R.id.cut
- 복사 : android.R.id.copy
- 붙여넣기 : android.R.id.paste

 

ActionMode.Callback 과 EditText 를 연결하는 Setter 는 두가지를 사용할 수 있다.

- customInsertionActionModeCallback
- customSelectionActionModeCallback 

 

customInsertionActionModeCallback 의 경우에는, EditText 내에 텍스트가 있거나 없거나 커서에 아무것도 선택되지 않았을 때 발생하는 콜백이라고 단순하게 생각하면 된다. customSelectionActionModeCallback 은 이미 텍스트가 입력이 되어있는 EditText 에 텍스트를 선택한 경우에 발생하는 콜백이라고 생각하면 쉽다. 그렇다면 단어를 선택하거나, 드래그를 통한 문자열 범위 선택에 나오는 컨텍스트 메뉴에 대해서도 customSelectionActionModeCallback 이 호출되는 것이다. 이 두 Setter 를 각각 연결해도 되고, 둘 다 연결해도 되고, 이것은 방향에 맞게 결정하면 된다.

 

연결이 완료되었다면 ActionMode.Callback 의 onActionItemClicked 를 구현해보도록 한다. 세부 기능은 알아서 처리해보도록 하고, print 만 찍어보며 확인할 수 있는 로직만 작성하도록 하겠다.

override fun onCreate(savedInstanceState: Bundle?) {
    // ...

    with(findViewById<EditText>(R.id.textarea)) {
        customInsertionActionModeCallback = DefaultActionModeCallback()
        customSelectionActionModeCallback = DefaultActionModeCallback()
    }
}

// ActionModeCallback 은 abstract 화 시킴
private class DefaultActionModeCallback: ActionModeCallback() {
    override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
        when(item?.itemId) {
            android.R.id.copy -> println("Copy")
            android.R.id.paste -> println("Paste")
            android.R.id.cut -> println("Cut")
        }
        
        mode?.finish()

    	// true 로 놓는 경우 컨텍스트 메뉴가 종료되지 않음
        // 그래서 ActionMode 에 있는 finish() 를 수동 호출해줘야 한다.
        return true
    }
}

onActionItemClicked 의 리턴값을 false 로 지정하게 된다면 시스템이 가지고 있던 일반적인 기능을 호출하게 되기 때문에, 일반기능과는 다른 커스터마이즈가 필요하다면 true 값으로 지정할 필요가 있다. 단, return true 인 경우에는 컨텍스트 메뉴가 사라지는 부분까지 처리해야 하며, 이 부분은 ActionMode 파라미터에 있는 finish 메소드를 사용하도록 한다.

 

2. 컨텍스트 메뉴를 추가 / 삭제 해보자

 

이번에는 컨텍스트 메뉴 항목을 추가하거나 삭제해보도록 한다. 메뉴를 구성하기 위해서는 onCreateActionMode 를 통해 가능하다. Setter 가 Insertion / Selection 두개가 있는 만큼, 두 가지 모드에 대해서 각각 처리를 할 수 있다. 그럼 간단하게 나눠 보도록 한다.

override fun onCreate(savedInstanceState: Bundle?) {
    //...
        
    with(findViewById<EditText>(R.id.textarea)) {
        customInsertionActionModeCallback = InsertionActionModeCallback()
        customSelectionActionModeCallback = SelectionActionModeCallback()
    }
}

// Insertion 에서 일어나는 ActionMode.Callback
private class InsertionActionModeCallback: ActionModeCallback() {
    override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
        return super.onCreateActionMode(mode, menu)
    }
}

// Selection 에서 일어나는 ActionMode.Callback
private class SelectionActionModeCallback: ActionModeCallback() {
    override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
        return super.onCreateActionMode(mode, menu)
    }
}

Insertion 에서 나타날 메뉴와 Selection 에서 나타날 메뉴를 다르게 하기 위해서 클래스를 구분하였다. 표현하기 쉽게 각각 임의의 메뉴를 추가해보도록 하겠다.

 

간단하고 빠르게 구현해보면,

override fun onCreate(savedInstanceState: Bundle?) {
    // ...

    with(findViewById<EditText>(R.id.textarea)) {
        customInsertionActionModeCallback = InsertionActionModeCallback()
        customSelectionActionModeCallback = SelectionActionModeCallback()
    }
}

private class InsertionActionModeCallback: ActionModeCallback() {
    companion object {
        private const val ID_INSERTION = 441321
    }

    override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
        menu?.add(Menu.FIRST, ID_INSERTION, Menu.NONE, "추가된메뉴")

        return super.onCreateActionMode(mode, menu)
    }

    override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
        if(item?.itemId == ID_INSERTION){
            println("추가된 메뉴")
            mode?.finish()

            return true
        }

        return super.onActionItemClicked(mode, item)
    }
}

private class SelectionActionModeCallback: ActionModeCallback() {
    companion object {
        private const val ID_SELECTION = 441324
    }

    override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
        menu?.add(Menu.FIRST, ID_SELECTION, Menu.NONE, "선택된메뉴")

        return super.onCreateActionMode(mode, menu)
    }

    override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
        if(item?.itemId == ID_SELECTION){
            println("선택된 메뉴")
            mode?.finish()

            return true
        }

        return super.onActionItemClicked(mode, item)
    }
}

 

각 Insertion 과 Selection 클래스에 메뉴를 추가하였고, 해당 메뉴를 클릭 할 때 어떤 동작을 해야 하는 지 클릭 이벤트도 분기처리 하는 로직이다. 이렇게 EditText 의 각 ActionMode 마다 콜백 클래스를 활용하여 기능을 부여할 수 있다

왼쪽은 Selection 콜백을 커스터마이즈 할 때, 오른쪽은 Insertion 메뉴를 커스터마이즈 할 때,

 

3. (기타) ActionMode.Callback 을 쓰지 않고 기본 컨텍스트 메뉴 기능을 후킹해보기

 

ActionMode.Callback 의 onActionItemClicked 를 통해 클릭을 후킹해봤지만, [잘라내기, 복사, 붙여넣기] 기능에 대해서는 ActionMode.Callback 을 통한 후킹 이외에도 다른 방식으로 후킹을 할 수 있다. 바로, TextView (EditText) 를 상속 받아 구현하는 경우이다. 특수한 메뉴 구성을 하지 않는 경우에는 이 방식도 고려해볼 수도 있을 것 같지만, 참고용으로만 기억하고 있을 필요가 있을 것 같다.

 

우선, TextView 내에는 onTextContextMenuItem 이라는 메소드가 있다. on 타입의 메소드이기 때문에 외부에서 호출하는 메소드가 아니라 상속을 통해 구현을 해야한다. EditText 는 TextView 의 확장형이기 때문에 EditText 내에도 해당 메소드를 가지고 있으니 필요시 상속 받아 구현할 수 있다.

 

이번에도 간단하게 구현해보자. 먼저, EditText 를 상속 받은 클래스를 준비하도록 한다. 이 클래스는 onTextContextMenuItem 을 오버라이드 했다.

class CEditText @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = android.R.attr.editTextStyle):
    EditText(context, attrs, defStyleAttr) {

    override fun onTextContextMenuItem(id: Int): Boolean {
        when(id){
            android.R.id.copy -> {
                println("Copy")
                return true
            }

            android.R.id.paste -> {
                println("Paste")
                return true
            }

            android.R.id.cut -> {
                println("Cut")
                return true
            }
        }

        return super.onTextContextMenuItem(id)
    }
}

 

다음은, XML 에 추가만 해주고 실행하면 된다. 

 

onTextContextMenuItem 은 ActionMode.Callback 의 onAcitonItemClicked 와 같이 리턴값을 true 로 설정하게 되면 컨텍스트 메뉴가 사라지지 않는다. 하지만 onTextContextMenuItem 에는 ActionMode 객체를 받지 않기 때문에 종료처리를 어떻게 해야 하나 싶다. 이럴 때는 clearFocus() 를 호출해주면 된다.

 

추가로, ActionMode.Callback 과 onTextContextMenuItem 을 같이 구현하는 경우, 클릭 이벤트의 순서는 ActionMode.Callback > onTextContextMenuItem 순으로 이어진다. 개발 방법은 편의대로 구현하면 될 것 같다

 

반응형
Comments