안드로이드에서는 라이브러리 차원에서 DrawerLayout, 일명 사이드 메뉴 또는 햄버거(hamburger) 버튼에 해당하는 메뉴 인터페이스가 지원되지만, iOS 쪽에는 그와 똑같은 기능이 따로 없다. iOS에서 DrawerLayout에 해당하는 인터페이스를 만들려면 써드 파티 라이브러리를 끌어오거나 다른 곳에서 코드를 복사/붙여넣기하는 방법 등 여러가지 길이 있겠으나 여기에서는 별도 라이브러리 없이 이와 유사한 인터페이스를 만들어보도록 하겠다.
앱 화면의 상단에 햄버거 버튼을 만들어야 하므로 앱의 인터페이스에 내비게이션을 적용한다. Xcode에서 기본으로 만들어 준 뷰 컨트롤러를 그대로 둔 채 [Editor] - [Embed In] - [Navigation Controller]를 클릭한다.
앱 인터페이스에 내비게이션을 적용한다.
Entry Point와 빈 화면 사이에 Navigation Controller가 새로 끼어들면서 기존의 빈 화면은 두 번째로 밀려났다. 이 두 번째 화면에 DrawerLayout과 비슷한 레이아웃을 구성한다. 다음과 같이 최상위 뷰(View)의 아래에 두 개의 뷰(View)를 두는데 하나의 뷰에는 기본적인 화면을 구성하고 또 다른 뷰에는 사이드 메뉴를 구성한 다음 그 두 개의 뷰를 포갠다. 사이드 메뉴로 쓰일 뷰의 폭은 다소 좁게 지정할 수 있는데 여기서는 300으로 지정한다. 좀 더 확실한 구별을 위해 사이드 메뉴로 쓰일 뷰의 배경은 오렌지 색으로 칠했다.
DrawerLayout을 고려하여 레이아웃을 작성한다.
그 다음 네비게이션 바의 좌측에 햄버거 버튼을 추가한다. 햄버거 버튼에 사용할 이미지를 구해서 넣는 과정까지는 생략한다. 혼동하지 말아야 할 것은 내비게이션 바에 추가되는 버튼의 형식은 UIBarButtonItem이라는 것이다. 이것을 내비게이션 바의 좌측에 추가한다.
내비게이션 바에 Bar Button Item을 추가한다(1).내비게이션 바에 Bar Button Item을 추가한다(2).내비게이션 바에 Bar Button Item을 추가한다(3).
2 단계. 로직 구성하기
겉 모습은 대강 위와 같이 만들어졌으므로 이제 코딩을 할 차례이다. 햄버거 버튼을 클릭할 때마다 사이드 메뉴가 나타나거나 사라지거나 해야 하는데, 이 방법에는 여러 가지가 있겠으나 여기에서는 constraint를 사용해서 사이드 메뉴의 폭을 유지한 채로 좌우로 이동시키는 방식을 사용하겠다. 다시 적으면, 평소에는 사이드 메뉴가 화면 바깥으로 완전히 나가 있다가 햄버거 버튼을 누르면 화면에 나타나도록 constraint를 조정하고 다시 햄버거 버튼을 누르면 사이드 메뉴를 화면 바깥으로 완전히 이동시키는 방식이다.
사이드 메뉴가 화면 안으로 들어왔을 때의 leading constraint.사이드 메뉴가 화면 밖으로 벗어났을 때의 leading constraint.
이와 같은 작동을 구현하기 위해 다음과 같이 최소한 2개의 IBOutlet과 1개의 IBAction이 필요하다.
class ViewController: UIViewController {
// sidebarView의 폭을 정의하는 상수입니다.
let sidebarViewWidth = 300
// ...
// 사이드 메뉴를 포함하고 있는 뷰(오렌지색 배경의 뷰)입니다.
@IBOutlet var sidebarView: UIView!
// sidebarView와 super view 사이에 적용되고 있는 Leading Constraint입니다.
@IBOutlet var sidebarViewLeadingConstraint: NSLayoutConstraint!
// ...
// 햄버거 버튼을 터치할 때마다 수행될 작동이 정의된 메소드입니다.
@IBAction func hamburgerNavigationItemTouchDown(_ sender: Any) {
}
}
Objective-C로는 다음과 같이 적을 수 있다.
// ViewController.m
// sidebarView의 폭을 정의하는 상수입니다.
#define SIDEBAR_VIEW_WIDTH 300
// ...
@interface ViewController ()
// 사이드 메뉴를 포함하고 있는 뷰(오렌지색 배경의 뷰)입니다.
@property(atomic) IBOutlet UIView * sidebarView;
// sidebarView와 super view 사이에 적용되고 있는 Leading Constraint입니다.
@property(atomic) IBOutlet NSLayoutConstraint * sidebarViewLeadingConstraint;
@end
@implementation ViewController
// 햄버거 버튼을 터치할 때마다 수행될 작동이 정의된 메소드입니다.
- (IBAction)hamburgerNavigationItemTouchDown:(id)sender {
}
@end
ViewController 클래스에서 선언한 총 3개의 메소드 및 변수들을 스토리보드와 reference 시킨다.
코드에서 작성한 멤버와 스토리보드 요소 사이를 reference 시킨다.
사이드 메뉴를 화면에 나타나도록 작동하는 코드는 다음과 같이 작성한다. 사이드 메뉴 뷰와 super view 사이에 적용되는 leading constraint 값을 0으로 하면 된다.
func showSidebarView() {
sidebarViewLeadingConstraint.constant = 0
// 애니메이션 효과
UIView.animate(withDuration: 0.5, delay: 0.0, options: .curveEaseIn, animations: {
self.view.layoutIfNeeded()
})
}
이번 시간에는 iOS 또는 iPadOS 앱에 커스텀 폰트를 내장시킨 후 이를 적용시키는 방법에 대해 정리한다.
1 단계. 커스텀 폰트 적용 전
전・후 비교를 확실하게 보기 위하여 일단 스토리보드에 적당한 텍스트를 추가한다. 시스템 폰트가 적용되어 있고, 폰트 목록에도 아직 커스텀 폰트가 나타나지 않는다.
목록에 시스템 기본 폰트들만 떠 있고, 텍스트 폰트도 기본 폰트만 적용되어 있다.
2 단계. 커스텀 폰트 추가
앱에서 사용하고자 하는 폰트 파일을 Xcode의 프로젝트 내비게이터(Project navigator) 안으로 복사해 넣는다. 여기에서는 무난한 나눔폰트(나눔명조)를 사용해 보겠다.
나눔명조체 파일을 Xcode 프로젝트 안으로 드래그하여 넣는다.
3 단계. 번들에 커스텀 폰트 인식
앱에 폰트가 추가되었음을 번들(bundle) 차원에서 알고 있어야 한다. 이를 위해 ① 프로젝트 내비게이터(Project navigator) - ② 최상단 항목 - ③ TARGET에서 해당 항목을 선택한 후 ④ Info 탭을 클릭한다. Custom iOS Target Properties에서 ⑤ [+] 모양의 버튼을 클릭하여 ⑥ "Fonts provided by application"이라는 이름이 붙은 변수를 새로 추가한다.
번들에게 새 폰트가 추가되었음을 알린다(1).
새로 추가할 폰트의 수만큼 Item을 추가한 다음 각 Item마다 폰트 파일의 이름을 정확하게 적는다.
번들에게 새 폰트가 추가되었음을 알린다(2).
4 단계. UI 요소에 커스텀 폰트 적용
그 다음 스토리보드로 돌아가서 커스텀 폰트를 적용한다. 폰트를 적용할 대상을 클릭하고 폰트 유형을 System에서 Custom으로 바꾼 다음, 폰트 이름을 클릭하여 폰트 목록을 연다.
UI 요소에 커스텀 폰트를 적용한다(1).
폰트 목록에서 앞의 1단계에서는 없었던 폰트가 추가된 것을 확인할 수 있다. 이것으로 앱에 커스텀 폰트 적용이 끝났다.
iPhone 또는 iPad의 언어 설정에 따라 홈 화면에 나타나는 앱의 이름을 다르게 보여주어야 할 필요가 발생할 수 있다. 예를 들어 카카오톡의 경우도 한국어 상태의 단말기에서는 홈 화면에서 앱 이름이 “카카오톡”과 같이 한글로 나타나지만, 영어 상태의 단말기에서는 앱 이름이 “KakaoTalk”과 같이 영문으로 나타난다. 사용자에게 나타나는 앱의 이름은 CFBundleDisplayName이라는 이름으로 번들(bundle)에 보관되게 된다.
단말기의 언어가 영어로 설정된 상태에서는 앱의 이름이 영어로 표시될 수 있다.단말기의 언어가 한국어로 설정된 상태에서는 앱의 이름이 한국어로 표시될 수 있다.단말기의 언어가 프랑스어로 설정된 상태에서는 앱의 이름이 프랑스어로 표시될 수 있다.단말기의 언어가 러시아어로 설정된 상태에서는 앱의 이름이 러시아어로 표시될 수 있다.단말기의 언어가 일본어로 설정된 상태에서는 앱의 이름이 일본어로 표시될 수 있다.
위와 같은 결과를 얻으려면 Xcode에서 다음과 같이 준비한다.
1 단계. 앱이 지원할 수 있는 언어 설정하기
Xcode의 좌측 프로젝트 내비게이터(Project navigator)에서 최상단 항목인 프로젝트를 선택한다. 그 다음 우측 편집 화면에서 프로젝트 이름을 선택한 후 [Info] 탭을 클릭한다.
앱이 지원할 수 있는 언어를 설정하기(1).
Localization 항목을 보면 기본으로 영어만 추가되어 있는데, [+] 버튼을 눌러 앱이 지원할 수 있는 언어들을 하나씩 클릭하여 추가시킨다.
앱이 지원할 수 있는 언어를 설정하기(2).
언어를 선택할 때마다 프로젝트가 현재 가지고 있는 리소스 파일들의 목록이 나타나며 이러이러한 파일들에 대해 다국어를 지원하겠다는 대화상자가 나타난다. 모두 선택해 주면 좋고, 일부만 선택해서 그것들만 다국어를 지원할 수도 있다. 일단 [Finish]를 눌러본다.
앱이 지원할 수 있는 언어를 설정하기(3).
여기에서는 영어, 한국어, 프랑스어, 러시아어 및 일본어의 5개 국어를 지원해 보겠다.
앱이 지원할 수 있는 언어를 설정하기(4).
앞서 언급된 CFBundleDisplayName라는 이름의 변수는 프로젝트의 info.plist에 정의된다. 그렇다고 info.plist를 열어서 마치 하드코딩처럼 문자열을 막 수정하는 것은 재미없으므로 유연한 방법으로 접근한다.
2 단계. info.plist에 대한 localization 적용하기
1 단계에서 앱이 지원할 수 있는 언어들을 결정한 후에 [File] 메뉴에서 새 파일을 추가한다.
localization 파일을 추가한다(1).
새로 추가될 파일의 유형은 strings이다.
localization 파일을 추가한다(2).
또한 파일의 이름은 InfoPlist.strings이므로 대화상자에는 InfoPlist를 입력한다. 파일 이름은 바꾸거나 할 수 없고, Xcode에서 항상 그렇게만 쓰겠다고 고정시킨 이름이기에 여기에 따른다.
앞서 언급된 CFBundleDisplayName 변수는 이 곳에 놓이게 된다. strings 파일이 추가된 직후에는 대개 영어 모드일 때의 문자열을 지정하게 되므로 다음과 같이 영문 앱 이름을 우선 작성해 본다.
"CFBundleDisplayName"="<영어 앱 이름>";
CFBundleDisplayName는 대소문자가 정확히 일치해야 하며 줄 끝에는 반드시 세미콜론(;)이 붙어야 한다. 그리고나서 화면 우측 메뉴의 [Localize] 버튼을 클릭한다. 앞서 1 단계에서 앱의 언어를 추가할 때마다 이러이러한 리소스 파일에 대해 localization을 적용하겠다고 대화상자가 뜬 것을 보았을 텐데, 이와 같이 해당 대화상자에서는 나타나지 않았던 info.plist와 같은 파일이나 나중에 추가된 리소스 파일 같은 것들은 이러한 방식으로 [Localize] 버튼을 클릭하여 localization을 적용할 수 있다.
기본 앱 이름을 적고 [Localize] 버튼을 클릭(1).
현재 편집중인 파일은 영어 모드일 때 사용자에게 보여줄 문자열로 간주하고 영어 리소스에 해당하는 폴더로 이동시키겠다는 대화상자이다. 딱히 수정해야할 것은 없어 보이므로 그냥 [Localize] 누른다.
기본 앱 이름을 적고 [Localize] 버튼을 클릭(2).
그러면 [Localize] 버튼이 있던 자리에 1 단계에서 설정했던 언어 목록들이 뜬 것을 볼 수 있다. localize할 언어들을 선택해 준다. 여기에서는 모두 다 선택한다.
기본 앱 이름을 적고 [Localize] 버튼을 클릭(3).
언어를 체크할 때마다 프로젝트 내비게이터(Project navigator)에서 InfoPlist.string가 해당 언어 버전으로 분화되는 것을 볼 수 있다. 파일을 하나씩 열어서 그 언어에 맞게 앱 이름을 적어준다. 그러면 단말기의 언어 설정에 따라 앱 이름이 다르게 나타날 것이다.
안드로이드 앱에서 SQLite는 로컬 데이터베이스로 쓰이고 있다. SQLite를 통해 데이터베이스에 접근하기 위해 이전까지는 DatabaseHelper 클래스를 파생 및 구현시키는 방법을 써 왔으나, 룸(Room)이라는 것이 새로 제공되어 이를 정리해 두고자 한다. 결론부터 말하면 Helper 클래스를 구현하면서 메소드별로, 테이블별로 일일이 커서를 읽고 형변환하고 예외처리하는 등의 귀찮은 작업은 이제 컴파일러가 알아서 도맡을테니 메타 데이터로 껍데기만 알려주면 되는 편리한 기능이다.
컴파일러 의존성 설정하기
2021년 기준 최신 Android Studio에서 Room을 사용하고자 한다면 다음과 같이 gradle에 의존성을 추가하면 된다. 이 역시 나중에 Android Studio가 버전 상승하면 할 필요가 없어지려나?
dependencies {
// ... 기본적으로 생성되는 코드들은 생략하고 끝에 이런 내용들을 추가한다.
// 2021년 현재 2.3.0이 최신 버전이나 2.2.5를 써 보겠다.
def $room_version = "2.2.5"
// 클래스나 매개변수 앞에 붙는 annotation을 처리하기 위함
implementation 'androidx.room:room-runtime:$room_version'
annotationProcessor 'androidx.room:room-compiler:$room_version'
// 아래 내용이 없으면 나중에 실행할 때
// 클래스명_Impl does not exists라는 오류가 발생함
apply plugin: 'kotlin-kapt'
kapt 'androidx.room:room-compiler:$room_version'
물론 다음과 같이 버전을 하드코딩해도 된다. 나중에 룸의 버전이 올라갔을 때 불편할 수는 있겠다.
dependencies {
// ... 기본적으로 생성되는 코드들은 생략하고 끝에 이런 내용들을 추가한다.
apply plugin: 'kotlin-kapt'
kapt 'androidx.room:room-compiler:2.2.5'
implementation 'androidx.room:room-runtime:2.2.5'
annotationProcessor 'androidx.room:room-compiler:2.2.5'
kapt라는 것이 등장하는데, 이것은 kotlin 컴파일러에 부착되어서 돌아가는 플러그인 같은 것이다.
엔티티를 나타내는 클래스 선언하기
엔티티(entity), 행(row), 레코드(record)... 라고 불리는 것을 클래스 형태로 선언할 차례다. 예를 들어 SQL로 다음과 같이 생성된 테이블에 접근하고자 한다.
CREATE TABLE "Entity" (
"id" INTEGER NOT NULL UNIQUE,
"title" TEXT NOT NULL,
"content" TEXT,
"data" BLOB,
PRIMARY KEY("id")
);
CREATE INDEX "IndexEntity" ON "Entity" (
"id" ASC
);
id는 정수형 값이고 NULL을 허용하지 않으며 테이블 내에서 유일해야 하고 인덱스까지 되어 있다고 가정한다. title은 NULL을 허용하지 않는 문자열이다. content도 문자열이지만 NULL을 허용한다. data는 이진(binary) 데이터이다.
이 테이블에 보관되는 각 행을 표현하는 kotlin 클래스는 다음과 같이 적을 수 있다.
@Entity(tableName = "Entity", primaryKeys = ["id"], indicies = [Index(name = "IndexEntity", value = "id")]
class Entity(@ColumnInfo(name = "id", typeAffinity = ColumnInfo.INTEGER)
var id: Int,
@ColumnInfo(name = "title", typeAffinity = ColumnInfo.TEXT)
var title: String,
@ColumnInfo(name = "content", typeAffinity = ColumnInfo.TEXT)
var content: String?,
@ColumnInfo(name = "data", typeAffinity = ColumnInfo.BLOB)
var data: ByteArray?) {
// ...
}
title과 content의 차이에 주목한다. 하나는 NULL을 허용하지 않고 다른 하나는 허용한다. 이 차이는 annotation에서 드러나지 않는다. 둘 다 타입이 ColumnInfo.TEXT로 되어 있기 때문이다. 대신 매개변수의 형식을 본다. 하나는 String이고 다른 하나는 nullable인 String?이다.
SQLite에서 BLOB으로 선언한 필드의 경우 kotlin과 연계 시 그 자료형은 ByteArray로 될 수 있다.
테이블을 생성할 때 사용한 SQL 구문의 내용과 코틀린 클래스의 annotation으로 지정한 내용에 차이가 있을 경우 런타임 시 테이블 선언이 서로 다르다는 오류를 뱉을 수 있다.
엔티티에 대해 할 수 있는 조회, 삽입 및 삭제 작업 선언하기
이전까지는 Helper 클래스를 작성하면서 이를 직접 구현하였다. SQL 구문을 구성해서 쿼리 수행하고, 결과를 읽어야 한다면 커서(Cursor)를 열고 필드 순번으로써 해당 필드를 접근하고 예외 발생 시 오류처리 등등... 이것이 룸(Room)을 사용하면 단순히 인터페이스 선언만으로 축약된다. 인터페이스를 구현하는 작업은 앞서 플러그인의 버프를 받는 컴파일러가 수행한다. 즉, 인터페이스에 대한 클래스 구현은 컴파일러가 알아서 해 준다. 이와 같이 테이블의 행들을 가지고 작업할 수 있는 객체를 DAO(Database Access Object)라 한다.
@Dao
interface IEntityAccess {
@Query("SELECT * FROM Entity")
fun select(): List<Entity>
@Query("SELECT * FROM Entity WHERE title LIKE :condition")
fun select(condition: String?): List<Entity>
}
이런 식으로 쿼리를 작성한다. 메소드의 반환형은 대체로 리스트면 무난할 것 같다. 매개변수는 쿼리 안에서 콜론을 붙이고 매개변수 이름을 그대로 적으면 된다.
UPDATE, INSERT, UPDATE 등의 코드는 annotation만 작성하면 된다. 쿼리는 알아서 작성해준다. 이 때 기본 키를 가지고 특정 행을 식별하는데, 기본키에 해당하는 필드 자체를 수정할 수는 없다. 기본키 필드에 담긴 값 자체를 수정하려면 위와 같이 쿼리를 직접 작성해야 한다.
@Dao
interface IEntry2Access {
@Insert
fun insert(entry: Entry2)
@Update
fun update(entry: Entry2) // 수정할 행의 기본 키와 새로 수정할 내용이 담긴 객체
@Delete
fun delete(entry: Entry2)
}
데이터베이스 클래스 작성하기
엔티티를 정의했고, 그 엔티티에 대해 수행하는 작업을 정의했다. 그러한 엔티티들을 포함하는 단위인 데이터베이스도 클래스로 작성할 차례이다.
@Database(entities = [Entity::class, Entity1::class, Entity2::class], version = 1, exportSchema = false)
abstract class Database : RoomDatabase() {
abstract fun accessEntity(): IEntityAccess
abstract fun accessEntity1(): IEntityAccess
companion object {
private val instance: Database? = null
@Synchronized
fun getInstance(context: Context): Database {
if (instance == null) {
synchronized(Database::class) {
instance = Room.databaseBuilder(
context,
Database::class.java,
파일경로).build()
}
}
return instance!!
}
}
}
asset에 이미 구촉된 데이터베이스가 있다면 이것을 앱 로컬 경로로 복사하는 식으로 생성한다.
이전 게시글에서 ActionBar를 Toolbar로 대체한 다음 햄버거 버튼까지 추가해 보았다. 본 게시글에서는 햄버거 버튼을 클릭하면 사이드 메뉴가 나타나는 것까지 구현해 보겠다. 안드로이드에서는 햄버거 버튼을 클릭했을 때 측면에서 스르륵 나타나는 사이드 메뉴를 DrawerLayout이라고 부른다. 이전 게시글의 레이아웃에서 이를 덧붙여보겠다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val toolbar = findViewById<Toolbar>(R.id.toolbar)
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_menu)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
// ...
return super.onCreateOptionsMenu(menu)
}
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
// ...
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
var result = false
when (item.itemId) {
// ...
}
return result;
}
}
ConstraintLayout을 기준으로 설명하면, 맨 위에 Toolbar가 있고 그 아래에 붙여서 DrawerLayout을 배치할 것이다. 그러면 레이아웃은 다음과 같이 작성할 수 있다.
<androidx.constraintlayout.widget.ConstraintLayout ...>
<androidx.appcompat.widget.Toolbar android:id="@+id/toolbar" ...>
</androidx.appcompat.widget.Toolbar>
<androidx.drawerlayout.widget.DrawerLayout
android:id="@+id/layout_drawer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="이 곳은 메인 영역이다." />
<com.google.android.material.navigation.NavigationView
android:id="@+id/navigation_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="이 곳은 사이드 영역이다." />
</com.google.android.material.navigation.NavigationView>
</androidx.drawerlayout.widget.DrawerLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
TextView는 영역 사이의 구분을 명확히 하기 위하여 임의로 넣은 요소이다. 아무튼 레이아웃을 저렇게 작성한다. DrawerLayout 내부에 사이드 메뉴가 보여질 NavigationView가 있다는 것이다. 특히 주목할 것은 NavigationView가 갖는 android:layout_gravity="start" 속성이다. 이 속성은 사이드 메뉴가 왼쪽에서 나타났다가 왼쪽으로 사라짐을 의미한다. 오른쪽 사이드 메뉴는 android:layout_gravity="end"로 하면 된다.
그 다음 이전 포스트에서 햄버거 메뉴를 클릭할 때 호출되는 이벤트는 다음과 같이 처리한다. 사이드 메뉴가 이미 떠 있다면(isDrawerOpen) 그것을 감추고(closeDrawer), 사이드 메뉴가 감춰져 있다면 그것을 드러낸다(openDrawer).
class MainActivity : AppCompatActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
var result = false
when (item.itemId) {
android.R.id.home -> {
val layoutDrawer = findViewById<DrawerLayout>(R.id.layout_drawer)
val gravity = GravityCompat.START
if (layoutDrawer.isDrawerOpen(gravity)) {
layoutDrawer.closeDrawer(gravity)
} else {
layoutDrawer.openDrawer(gravity)
}
}
}
return result;
}
}
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
3 단계. ActionBar를 지우기
2 단계에서 아무것도 건드리지 않고 그냥 실행을 해 보겠다. 그러면 다음과 같이 상단에 ActionBar를 포함하는 빈 화면이 나타난다.
기본 설정에 따라 ActionBar가 추가되어 있는 빈 액티비티
여기에서부터 하나씩 수정해나가겠다. 앞서 확인했던 /res/values/themes/themes.xml 파일을 다시 열고 앱이 정의하고 있는 기본 테마를 ActionBar에서 NoActionBar 계열로 바꾼다. 그러면 AndroidManifest.xml의 application에서 themes.xml에서 정의된 테마를 참조하고 그 결과가 application의 일부인 MainActivity에도 반영된다.
NoActionBar 테마를 적용한다.
정리하자면,
themes.xml이 Theme.MaterialComponents.DayNight.NoActionBar로부터 파생된 Theme.Testapp라는 이름의 새 테마를 정의한다.
AndroidManifest.xml이 Theme.Testapp를 참조하여 앱의 전반적인 테마를 적용한다.
MainActivity도 여기에 영향을 받아 ActionBar가 사라진다.
NoActionBar가 적용된 앱의 모습
4 단계. ActionBar가 사라진 자리에 Toolbar 넣기
이전 버전의 Android Studio에서는 Toolbar의 이름이 다음과 같이 android.support.v7.widget.Toolbar였다.
그러나 2021년 현재 최신 Android Studio에는 이 코드가 오류가 난다. Toolbar의 이름은 다음과 같이 바뀌었다. 물론 gradle에서 의존성을 수정하고 번거로운 세팅을 하면 옛날 이름으로 쓸 수 있겠으나, 이제부터 개발하는 앱은 기본 설정을 사용하는 것으로...
여기서 app:showAsAction 속성은 메뉴 항목을 Toolbar 우측 [더 보기...] 버튼을 눌러야 나타나게 할 것인지 여부를 지정한다.
never는 해당 항목을 무조건 [더 보기...] 버튼을 눌러야만 나타나게 한다.
ifRoom은 화면의 폭이 충분할 때는 Toolbar에 직접 나타내고 좁을 때는 [더 보기...] 버튼을 통해 보여지게 한다.
always라고 해서 화면의 폭과 무관하게 무조건 Toolbar에 직접 나타내는 옵션이 있는데 이는 IDE에서 권장하지 않고 ifRoom을 사용하도록 유도하고 있다.
설명보다는 직접 보고 정리하면 되겠다.
메뉴를 정의하는 xml 파일
레이아웃에 메뉴 적용하기
레이아웃 파일의 androidx.appcompat.widget.Toolbar로 돌아가서 다음의 속성을 추가하면 Toolbar에 메뉴가 적용된다.
app:menu="@menu/파일.xml"
"바나나" 항목은 app:showAsAction="ifRoom"으로 지정했으므로 Toolbar에 바로 나타난다.
ifRoom 값을 지정한 메뉴 항목
app:showAsAction="never"로 지정한 항목은 [더 보기...] 버튼을 눌러야 나타난다.
never 값을 지정한 메뉴 항목
6 단계. 메뉴 항목마다 이벤트 작성하기
Toolbar와 menu의 겉 모양은 대충 만들었으니 비하인드 코드를 작성할 차례이다. kotlin 소스 코드를 열고 액티비티에 다음과 같이 메소드를 오버라이드한다.
class MainActivity: AppCompatActivity() {
// ...
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
Log.d("menu", "onCreateOptionsMenu")
menuInflater.inflate(R.menu.menu_toolbar, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
Log.d("menu", "onPrepareOptionsMenu")
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
var result = false
when (item.itemId) {
R.id.action_item0 -> { Log.d("menu", "바나나 선택함"); result = true }
R.id.action_item1 -> { Log.d("menu", "포도 선택함"); result = true }
R.id.action_item2 -> { Log.d("menu", "딸기 선택함"); result = true }
}
return result;
}
}
각 메소드가 언제 실행되는지 Logcat으로 찍어보면 알 수 있다
onCreateOptionsMenu는 액티비티에 딸려 있는 메뉴를 최초로 구성할 때 1회만 호출된다. 이것을 재정의하여 menuInflater.inflate를 호출해준다. menuInflater.inflate 메소드가 무엇인가 하니, 리소스로부터 메뉴 XML 파일을 불러와서 메뉴로 띄워주는 역할을 한다. 레이아웃에서 지정했던 app:menu="@menu/파일.xml"과 동일한 것이다. 다만 레이아웃에서 app:menu="@menu/파일.xml"을 굳이 적지 않고 menuInflater.inflate 메소드만 호출해도 앱 실행 시 메뉴가 나타났지만, 반대로 menuInflater.inflate 메소드를 호출하지 않고 레이아웃에게 app:menu="@menu/파일.xml"만을 지정하면 메뉴가 나타나지 않았다. 그냥 그렇다는 거다.
onPrepareOptionsMenu는 메뉴를 최초로 구성할 때는 물론이고 [더 보기...] 버튼을 클릭하여 메뉴가 펼쳐질 때마다 호출된다. 사용자에게 메뉴 항목을 보여주기 전 뭔가를 메뉴 항목을 업데이트할 때 유용할 것으로 보인다.
onOptionsItemSelected는 메뉴 항목을 클릭/터치할 때 호출된다. 어떤 메뉴가 클릭/터치되었는지는 매개변수로 넘어온 item: MenuItem의 menu.itemId를 통해 식별 가능하다.
7 단계. 햄버거 버튼 만들기
이렇게까지 ActionBar를 굳이 ToolBar로 바꾸어서 버튼들을 추가하는 이유 중 하나는 사이드 바 메뉴, 일명 "햄버거 버튼(hamburger button)"을 만들기 위함일 것이다. 햄버거 버튼을 추가해 보겠다.
햄버거 이미지 준비하기
먼저 햄버거 아이콘을 준비한다. 직접 만들 필요도 없고, 구글링 통해 굳이 직접 구할 필요도 없다. Android Studio의 클립아트에 미리 준비되어 있다. drawable 디렉토리에 새로운 Vector(Image) Asset을 추가해본다.
New 메뉴에서 Vector(Image) Asset 항목 클릭
여기서는 벡터 이미지를 넣어보겠다. 클립아트 옆의 그림을 클릭하면 미리 준비된 아이콘들의 목록이 나타난다. 이 중에서 햄버거 아이콘(이름: menu)을 찾아서 클릭하고, 색상과 크기(일단은 넉넉하게 크기를 지정해 준다)를 적절하게 넣어주면 끝.
표시된 부분을 클릭menu라고 이름이 붙은 아이콘 선택
그리고 onCreate 메소드로 가서 홈(Home) 버튼이 Toolbar에 생겨나도록 다음과 같이 문장을 작성한다. 앞서 만들어 본 메뉴 항목과 달리 햄버거 아이콘은 대체로 화면의 왼쪽에 위치할텐데, Toolbar의 왼쪽에 생기는 아이콘을 홈 버튼이라 한다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val toolbar = findViewById<Toolbar>(R.id.toolbar)
setSupportActionBar(toolbar)
// 새로 추가된 두 줄
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_menu)
}
}
setDisplayHomeAsUpEnabled은 매개변수에 true를 전달함으로써 Toolbar에 홈 버튼이 나타나게 한다. setHomeAsUpIndicator은 홈 버튼으로 표시할 아이콘을 지정하는데, 위에서 클립아트를 통해 추가한 햄버거 아이콘의 아이디를 적으면 된다.
그러면 이와 같이 넉넉한 사이즈로 햄버거 아이콘이 나타난다
이런 모양을 원했던 것이 아니다. 그렇다면 화면 크기에 맞게 이미지 크기를 축소해주어야 한다. 여기서는 벡터 형식으로 아이콘을 추가했으므로 크기 조정이 매우 간편하다. 햄버거 아이콘 파일을 열고 폭(android:width)과 높이(android:height)를 ?attr/actionBarSize로 수정한다. 그러면 다음과 같이 제법 그럴듯한 아이콘이 만들어진다.
이것도 크다 하면 마진(margin)을 좀 더 주는 식으로 줄일 수 있다. path element를 group으로 감싼 다음 scaleX와 scaleY를 1.0보다 작은 값으로 지정한다. 그러면 path가 표현하는 햄버거 아이콘이 좀 더 축소된다. 예를 들어 scaleX="0.8" scaleY="0.8"로 하면 가로와 세로를 본래의 80%로 축소시킨다.
좀 더 보기 좋아졌으나 뭔가 아쉬운 아이콘
크기는 좀 줄었는데 위치가 애매해졌다. 아이콘을 버튼 영역의 정중앙에 오도록 하려면 pivotX와 pivotY를 각각 android:viewportWidthM의 절반, android:viewportHeight의 절반으로 지정하면 된다.
9 단계까지 잘 오셨습니다! 본 단계도 독자 여러분께서 요청하셨던 사항이었고, 특정 형식의 파일(여기에서는 텍스트 파일) 각각에 대해 사용자화된 아이콘을 보여주는 방법에 대해 다루어 보겠습니다. 첨부된 예제 프로젝트는 모든 버전의 Windows에서 실행될 것입니다.
Visual C++ 7.0 또는 8.0 사용자는 이전과 같이 1 단계의 본 시리즈에 들어가며......를 참고하여 컴파일하기 전 몇 가지 설정을 변경해야 함을 기억하시기 바랍니다.
Windows 탐색기에서 각각의 유형의 파일들은 특정한 아이콘으로 표현됨을 모두들 아실 것입니다. 비트맵 이미지는 페인트 브러시 아이콘으로 표시되고, HTML 페이지는 인터넷 익스플로러 아이콘이 그려진 종이 모양의 아이콘으로 표시됩니다. Windows 탐색기는 파일에 대해 사용자에게 어떤 아이콘으로 보여줄 것인지 여부를 레지스트리를 찾아가며 결정합니다. 그리고 파일 유형에 따라 그러한 아이콘 정보를 가지고 있는 레지스트리 키는 HKEY_CLASSES_ROOT의 하위 키입니다. 이 방식은 특정한 형식의 모든 파일이 전부 같은 모양의 아이콘으로 보여지는 결과를 갖습니다.
그러나 아이콘을 지정하는 방식이 이것뿐만은 아닙니다. 아이콘 핸들러(icon handler)라는 쉘 익스텐션을 작성함으로써 Windows 탐색기는 우리가 각각의 파일마다 아이콘을 다르게 사용자화할 수 있게 만들어 줍니다. 사실, 파일 별로 아이콘을 다르게 나타나게 하는 예는 이미 Windows 운영체제 자체에 내장되어 있습니다. Windows 디렉토리 또는 .exe파일들이 많은 디렉토리를 Windows 탐색기로 열어 보시기 바랍니다. 여러분은 각각의 .exe 파일이 자신만의 아이콘으로 표시되고 있음을 알 수 있습니다. 단 이 경우 .exe 파일에 자체 아이콘 리소스를 가지고 있는 경우에 한하며, 그렇지 않은 .exe 파일들은 일반적은 아이콘으로 나타납니다. .ico나 .cur 등의 파일도 각 파일마다 서로 다르게 아이콘으로 나타납니다.
이 단계에서 다룰 예제 프로젝트는 텍스트 파일에 대해 사이즈별로 각기 다른 4개의 아이콘을 보여주는 기능을 합니다. 화면에 표시된 아이콘은 다음과 같습니다.
쉘 익스텐션의 실행 결과.
확장 인터페이스
여러분은 프로젝트를 생성하는 과정에 이미 익숙해져 있을 것입니다. 그러므로 필자는 Visual C++ 마법사 단계에 대해서는 생략하겠습니다. ATL COM 프로젝트를 생성하는 데, 그 이름은 TxtFileIcons로 정하고, C++ 구현 클래스의 이름은 CTxtIconShlExt로 정합니다.
아이콘 핸들러는 두 개의 인터페이스를 구현하고 있습니다. 하나는 IPersistFile이고 다른 하나는 IExtractIcon입니다. IShellExtInit는 한 번에 여러 파일이 선택된 상태에서 쉘 익스텐션을 초기화하는 반면, IPersistFile은 한 번에 하나의 파일에 대해서만 쉘 익스텐션을 초기화함을 기억하시기 바랍니다.
IExtractIcon은 두 개의 메소드를 가지고 있는데 둘 다 Windows 탐색기에게 특정 파일에 대해 어떤 아이콘을 사용할 것인지에 대한 내용을 전달해주는 역할을 합니다.
Windows 탐색기는 보여지는 파일들 모두에 대해 COM 객체를 생성함을 기억하시기 바랍니다. 이것은 매 파일마다 C++ 클래스의 인스턴스가 생성됨을 의미합니다. 그러므로 Windows 탐색기가 버벅거리는 것을 피하기 위하여, 이번 쉘 익스텐션에서 여러분은 시간을 소비하는 작업을 피하셔야 합니다.
초기화 인터페이스
COM 객체에 IPersistFile 인터페이스를 추가하기 위하여 TxtIconShlExt.h를 열고 아래 표시한 내용과 같이 수정합니다.
#include <comdef.h>
#include <shlobj.h>
#include <atlconv.h>
class CTxtIconShlExt :
public CComObjectRootEx<CComSingleThreadModel>,
public CComCoClass<CTxtIconShlExt, &CLSID_TxtIconShlExt>,
public IPersistFile { // 새로 추가
BEGIN_COM_MAP(CTxtIconShlExt)
COM_INTERFACE_ENTRY(IPersistFile) // 새로 추가
END_COM_MAP()
// 이 이하는 전부 새로 추가
public:
// IPersistFile
STDMETHOD(GetClassID)(CLSID *) { return E_NOTIMPL; }
STDMETHOD(IsDirty)() { return E_NOTIMPL; }
STDMETHOD(Save)(LPCOLESTR, BOOL) { return E_NOTIMPL; }
STDMETHOD(SaveCompleted)(LPCOLESTR) { return E_NOTIMPL; }
STDMETHOD(GetCurFile)(LPOLESTR *) { return E_NOTIMPL; }
STDMETHOD(Load)(LPCOLESTR wszFile, DWORD /*dwMode*/) {
USES_CONVERSION;
lstrcpyn(m_szFilename, OLE2CT(wszFile), MAX_PATH);
return S_OK;
}
protected:
TCHAR m_szFilename[MAX_PATH]; // 요청 받은 파일에 대한 전체 경로
DWORDLONG m_qwFileSize; // 파일의 크기
};
IPersistFile을 사용하는 다른 쉘 익스텐션과 마찬가지로 구현을 필요로 하는 단 하나의 메소드는 Load 뿐입니다. 왜냐하면 이 메소드를 통해 우리가 어떤 파일에 대해 작업해야 하는 지를 Windows 탐색기가 알려줄 것이기 때문입니다. Load의 구현은 인라인(inline)으로 작성되었습니다. 나중에 사용하기 위해 현재 파일의 이름을 m_szFilename 멤버 변수로 복사만 합니다.
IExtractIcon 인터페이스
아이콘 핸들러는 Windows 탐색기가 파일에 대한 아이콘을 필요로 할 때 호출할 메소드가 담긴 IExtractIcon 인터페이스를 구현합니다. 우리가 만들 쉘 익스텐션은 텍스트 파일에 대한 것이기 때문에, Windows 탐색기 창 또는 시작 메뉴에서 텍스트 파일이 보여지는 매 순간마다 Windows 탐색기는 우리가 구현할 IExtractIcon을 호출할 것입니다. COM 개체에 IExtractIcon을 추가하기 위하여 TxtIconShlExt.h를 열고 다음과 같이 내용을 추가합니다.
class CTxtIconShlExt :
public CComObjectRootEx<CComSingleThreadModel>,
public CComCoClass<CTxtIconShlExt, &CLSID_TxtIconShlExt>,
public IPersistFile,
public IExtractIcon { // 새로 추가
BEGIN_COM_MAP(CTxtIconShlExt)
COM_INTERFACE_ENTRY(IPersistFile)
COM_INTERFACE_ENTRY(IExtractIcon) // 새로 추가
END_COM_MAP()
public:
// IExtractIcon
STDMETHODIMP GetIconLocation(UINT uFlags, LPTSTR szIconFile, UINT cchMax, int * piIndex, UINT * pwFlags);
STDMETHODIMP Extract(LPCTSTR pszFile, UINT nIconIndex, HICON * phiconLarge, HICON * phiconSmall, UINT nIconSize);
};
Windows 탐색기에게 아이콘을 반환하는 방법에는 두 가지가 있습니다.
먼저 GetIconLocation 메소드는 아이콘을 포함하고 있는 파일의 이름 및 그 인덱스 쌍을 반환할 수 있고 이 때 인덱스는 해당 파일 내에서 아이콘을 특정하기 위해 0부터 시작하는 정수입니다. 예를 들어 C:\Windows\system32\shell32.dll/9와 같은 문자열이 반환될 수 있는데 이는 Windows 탐색기에게 shell32.dll 파일의 9번째 아이콘(0부터 셌을 때 9번째)을 알려줍니다. 다만 이와 같은 문자열은 해당 아이콘의 리소스 아이디가 9번이라는 뜻을 의미하는 것이 아닙니다. 리소스 아이디 순서(작은 값에서 큰 값 순으로)대로 볼 때 9번째 항목이라는 뜻입니다.
Extract 메소드는 아무 작업도 하지 않지만, S_FALSE를 반환하여 Windows 탐색기가 직접 아이콘을 추출하라고 알리게 됩니다.
특별한 사항이 있다면, GetIconLocation이 값을 반환한 뒤에 Windows 탐색기는 Extract 메소드를 호출할 수도 있고 그렇지 않을 수도 있다는 것입니다. Windows 탐색기는 최근에 사용한 아이콘들을 유지하고 있는 ‘아이콘 캐시(icon cache)’를 가지고 있습니다. GetIconLocation이 최근에 사용된 파일 이름 및 인덱스 쌍을 반환하고 아이콘 캐시에 이에 해당하는 아이콘이 있다면, Windows 탐색기는 Extract를 호출하지 않고 캐시된 아이콘을 사용합니다.
이 메소드는 또한 “캐시를 찾지 말 것”을 의미하는 플래그(flag)를 반환할 수도 있습니다. 이 경우 Windows 탐색기는 항상 Extract 메소드를 호출하게 되는데, 이렇게 하면 Extract는 Windows 탐색기가 보여줄 아이콘에 대한 핸들을 반환하기 위해 직접 아이콘을 로드(load)해야 합니다.
첫 번째 아이콘 추출 방법
처음으로 호출되는 IExtractIcon 메소드는 GetIconLocation입니다.
이 메소드는 IPersistFile::Load에서 보관한 파일 이름을 사용하여, 파일을 직접 읽은 후 앞서 설명한 파일 이름 및 인덱스 쌍을 반환합니다. GetIconLocation의 원형은 다음과 같습니다.
쉘 익스텐션의 작동을 바꿀 수 있는 옵션들입니다. GIL_ASYNC는 아이콘 추출 작업이 오래 걸릴 것인지 물어보기 위해 전달되는 플래그(flag)로, 만일 오래 걸린다면 쉘 익스텐션은 Windows 탐색기에게 아이콘 추출 작업을 백 그라운드에서 수행해 줄 것을 요청할 수 있습니다. 이렇게 하면 Windows 탐색기 창 자체가 버벅거리는 일이 없습니다.
다른 플래그(flag)인 GIL_FORSHELL 및 GIL_OPENICON은 네임스페이스 확장에서 의미 있습니다. 우리가 작업할 프로젝트에서는 실행 시간이 오래 걸리는 코드가 없으므로, 이러한 플래그(flag)들을 고려하지 않아도 됩니다.
szIconFile, cchMax
szIconFile은 쉘(shell)이 제공하는 버퍼로서 우리는 사용하고자 하는 아이콘이 포함된 파일의 이름을 이 곳에 보관하면 됩니다. cchMax는 쉘(shell)이 제공한 버퍼의 크기로서, 단위는 문자 수 입니다.
piIndex
우리가 사용하고자 하는 아이콘이 szIconFile에서 몇 번째 인덱스에 해당하는지를 지정할 정수에 대한 포인터입니다(번역자 주: 이것도 쉘(shell)이 제공하는 변수를 가리키는 유효한 포인터입니다).
pwFlags
쉘(shell)이 제공한 UINT형 변수를 가리키는 유효한 포인터로서, 반환하는 플래그(flag)에 따라 Windows 탐색기의 작동을 우리가 변경할 수 있습니다. 이 곳에 들어갈 수 있는 플래그의 종류에 대하여는 잠시 후 설명하겠습니다.
GetIconLocation 메소드는 szIconFile과 piIndex 매개 변수(parameter)에 내용을 채운 다음 S_OK를 반환합니다. 우리가 커스텀 아이콘을 제공하지 않기로 결정하였다면 S_FALSE를 반환합니다. 이 때 Windows 탐색기는 일반적인 아이콘(알 수 없는 파일)으로 표시해 줄 것입니다.
pwFlags를 통해 반환할 수 있는 플래그(flag)들에는 다음과 같은 종류가 있습니다.
GIL_DONTCACHE
Windows 탐색기가 szIconFile/piIndex로 지정된 파일이 최근에 사용되었는지 여부를 확인하기 위해 캐시를 검색하는 것을 못하게 합니다. 그 결과 IExtractIcon::Extract 메소드가 항상 호출됩니다. ‘두 번째 아이콘 추출 방법’에서 이 플래그에 대해 많은 이야기를 하겠습니다.
GIL_NOTFILENAME
MSDN에 따르면 GetIconLocation이 값을 반환할 때 이 플래그가 있으면 Windows 탐색기가 szIconFile/piIndex를 무시한다고 합니다. 명백하기도 이 플래그(flag)는 쉘 익스텐션이 어떻게 Windows 탐색기로 하여금 IExtractIcon::Extract를 호출하게 만드는지를 보여주는 플래그임이 확실한데, 실제로는 GetIconLocation이 값을 반환할 때 이 플래그는 Windows 탐색기의 작동에 아무 영향을 미치지 못합니다. 이와 관련해서는 나중에 설명하겠습니다.
GIL_SIMULATEDOC
이 플래그(flag)는 Windows 탐색기가 쉘 익스텐션에서 반환된 아이콘을 가져가서, “한 쪽 귀퉁이가 접힌 종이” 모양의 아이콘과 합친 다음에, 그 합성된 아이콘을 해당 파일에 적용해서 보여주라는 의미를 갖습니다. 필자는 잠시 후 이 플래그의 결과를 보여드리겠습니다.
첫 번째 아이콘 추출 방법에서, 우리가 작성할 쉘 익스텐션의 GetIconLocation 함수가 텍스트 파일의 크기를 계산한 다음, 그 크기에 따라 0, 1, 2, 3의 인덱스를 반환할 것입니다. 이러한 방법은 한 가지 결함을 가지고 있습니다. 여러분은 각 아이콘들의 리소스 아이디들을 추적하고 이들 리소스가 올바른 순서로 놓여 있는지를 확인해야 합니다. 우리의 쉘 익스텐션은 4개의 아이콘만 다루기 때문에 ‘회계장부정리’와 같은 이러한 작업이 어렵지는 않습니다. 그러나 여러분이 더 많은 수의 아이콘을 다루거나, 프로젝트에서 아이콘을 추가 또는 제거한 경우라면 리소스 아이디를 주의하여 관리해야 합니다.
우리가 구현할 GetIconLocation 메소드는 다음과 같습니다. 먼저 우리는 파일을 열고 그 크기를 측정합니다. 이 과정에서 오류가 발생하면 Windows 탐색기가 기본 아이콘을 적용할 수 있도록 S_FALSE를 반환합니다.
다음으로 우리는 Windows 탐색기로부터 기본 작동을 얻기 위하여 pwFlags를 0으로 설정합니다. 즉 Windows 탐색기는 szIconFile/piIndex로 특정된 아이콘의 캐시 여부를 아이콘 캐시를 통해 확인하라는 의미입니다. 이와 같이 설정하면, IExtractIcon::Extract 메소드는 호출되지 않을 것입니다. 그리고 나서 우리는 S_OK를 반환하여 GetIconLocation 메소드가 성공했음을 알립니다.
// ...
*pwFlags = 0;
return S_OK;
}
우리는 Windows 탐색기에게 아이콘을 어디에서 찾을 수 있는지 말했기 때문에, 우리가 직접 구현하는 Extract 메소드는 항상 S_FALSE를 반환할 것입니다. 이는 Windows 탐색기에게 아이콘을 직접 추출하라는 의미입니다. 다음 절에서 필자는 Extract의 매개 변수(parameter)에 대해 설명하겠습니다.
본 쉘 익스텐션의 작동 결과는 다음과 같습니다. (번역자 주: 아이콘이 바뀌지 않는다면, 작업 관리자에서 explorer.exe 프로세스만 강제 종료한 뒤, 다시 실행해 봅니다.)
'아이콘' 보기(1).
'자세히' 보기(2).
'큰 아이콘' 보기(3).
여러분이 GetIconLocation 메소드에서 pwFlags 위치가 가리키는 값을 GIL_SIMULATEDOC으로 설정하면, 아이콘은 다음과 같이 보여질 것입니다.
'아이콘' 보기(2).
'자세히' 보기(2).
'큰 아이콘' 보기(2).
아이콘 보기와 큰 아이콘 보기 화면에 주목하시기 바랍니다. 우리가 제공하는 ‘작은’ 크기(16 * 16 버전)의 아이콘이 사용되었습니다. 작은 아이콘 보기에서, Windows 탐색기는 원래 작은 아이콘을 더욱 작게 축소시키고 있는데 이는 별로 예뻐 보이지 않습니다.
두 번째 아이콘 추출 방법
두 번째 방법은 우리의 쉘 익스텐션이 스스로 아이콘을 추출하여 Windows 탐색기의 아이콘 캐시를 우회하는 것을 포함하고 있습니다.
IExtraction::Extract 메소드는 항상 호출됩니다. 그리고 이 메소드는 아이콘들을 로드해서 큰 아이콘과 작은 아이콘이라는 두 개의 HICON을 Windows 탐색기에게 반환해야 합니다. 이 방법의 이점이 있다면 리소스 아이디를 순서에 맞추어 유지하는 것을 염려하지 않아도 된다는 것입니다. 다만 쉘 익스텐션이 Windows 탐색기의 아이콘 캐시를 우회하기 때문에 많은 수의 텍스트 파일이 들어있는 디렉토리를 탐색할 때 그 탐색 속도가 느려질 수 있습니다.
GetIconLocation은 첫 번째 방법과 유사합니다. 그러나 파일의 크기를 가져오기만 하면 되기 때문에 이 메소드에서 해야 할 일이 약간 줄어들었습니다.
STDMETHODIMP CTxtIconShlExt::GetIconLocation(UINT uFlags, LPTSTR szIconFile, UINT cchMax, int * piIndex, UINT * pwFlags) {
DWORD dwFileSizeLo, dwFileSizeHi;
HANDLE hFile;
hFile = CreateFile(m_szFilename, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE)
return S_FALSE; // 쉘에게 기본 아이콘을 사용할 것을 말합니다.
dwFileSizeLo = GetFileSize(hFile, &dwFileSizeHi);
CloseHandle(hFile);
if (dwFileSizeLo == (DWORD)-1 && GetLastError() != NO_ERROR)
return S_FALSE; // 쉘에게 기본 아이콘을 사용할 것을 말합니다.
m_qwFileSize = ((DWORDLONG)dwFileSizeHi) << 32 | dwFileSizeLo;
// ...
일단 파일의 크기가 보관되었으므로 Windows 탐색기에게 아이콘 캐시를 확인하지 말 것을 알리기 위해 pwFlags가 가리키는 위치의 값을 GIL_DONTCARE로 변경합니다.
우리는 szIconFile/piIndex을 통해 아이콘을 특정하지 않을 것이므로 Windows 탐색기가 szIconFile/piIndex를 무시할 수 있도록, 이 플래그를 반드시 설정해야만 합니다.
비록 필자가 테스트하고 있는 현재 버전의 쉘(shell)에서는 별 효과가 없다고 하더라도 GIL_NOTFILENAME 플래그도 포함되어 있습니다. 이 플래그의 문서화된 목적은 Windows 탐색기에게 우리가 szIconFile/piIndex의 내용을 채우지 않을 것임을 알리는 것인데, 이 플래그를 전달하는 것 자체는 의미가 없으므로(달리 말하면 어차피 우리는 Windows 탐색기에게 추출해갈 대상을 전해주지 않으므로), Windows 탐색기는 내부적으로 이 플래그의 존재 여부를 평가조차 하지 않는 듯 합니다. 뭐 어쨌든 차기 버전의 쉘(shell)에서는 GIL_NOTFILENAME 여부를 평가할 수도 있으므로 이 플래그를 명시적으로 넣어주는 것은 좋은 생각입니다.
아이콘이 담긴 파일과 그 내부의 인덱스 번호입니다. 이 두 값은 GetIconLocation에서 반환된 값과 같습니다.
phiconLarge, phiconSmall
작은 아이콘과 큰 아이콘을 사용하기 위해 Extract 메소드가 설정해야 하는 각각의 HICON입니다. 이들 포인터는 NULL인 채로 전달될 수 있습니다.
nIconSize
아이콘의 예상 크기입니다. 상위 워드(HIWORD)는 작은 아이콘의 디멘션(dimension, 어차피 같을 것이므로 가로 폭과 세로 높이를 아우르는 값)이고, 하위 워드(LOWORD)는 큰 아이콘의 디멘션입니다. 일반적인 조건에서 작은 아이콘은 16이고, 큰 아이콘은 32 또는 48인데 Windows 탐색기의 보기 모드가 무엇에 선택되어 있는지에 달려 있습니다. 큰 아이콘 보기는 32이고, 타일 보기 모드에서는 48입니다.
우리의 쉘 익스텐션에서는 GetIconLocation 메소드에서 파일이름 및 인덱스 쌍을 채워 넣지 않았으므로 pszFile와 nIconIndex를 무시할 수 있습니다. 우리는 파일 크기에 따른 큰 아이콘과 작은 아이콘을 로드(load)하여 Windows 탐색기에게 반환하면 됩니다.
STDMETHODIMP CTxtIconShlExt::Extract(LPCTSTR pszFile, UINT nIconIndex, HICON * phiconLarge, HICON * phiconSmall, UINT nIconSize) {
UINT uIconID;
// 파일 크기에 따라 어떤 아이콘을 사용할 것인지를 결정합니다.
if (m_qwFileSize == 0)
uIconID = IDI_ZERO_BYTES;
else if (m_qwFileSize < 4096)
uIconID = IDI_UNDER_4K;
else if (m_qwFileSize < 8192)
uIconID = IDI_UNDER_8K;
else
uIconID = IDI_OVER_8K;
// 아이콘을 로드합니다!
if (phiconLarge != NULL) {
*phiconLarge = (HICON)LoadImage(_Module.GetResourceInstance(), MAKEINTRESOURCE(uIconID), IMAGE_ICON, wLargeIconSize, wLargeIconSize, LR_DEFAULTCOLOR);
}
if (phiconSmall != NULL) {
*phiconSmall = (HICON)LoadImage(_Module.GetResourceInstance(), MAKEINTRESOURCE(uIconID), IMAGE_ICON,
wSmallIconSize, wSmallIconSize, LR_DEFAULTCOLOR);
}
return S_OK;
}
이제 됐습니다! Windows 탐색기는 우리가 반환하는 아이콘을 보여주게 될 것입니다. 한 가지 주목할 것은, 두 번째 방법을 사용하면서 GetIconLocation 메소드에 GIL_SIMULATEDOC 플래그를 사용하는 것은 아무 효과가 없다는 것입니다.
쉘 익스텐션을 등록하기
아이콘 핸들러는 해당 유형의 파일을 다루는 레지스트리 키의 하위키로 등록됩니다. 우리의 경우 텍스트 파일을 다루고 있으므로 HKCR\txtfile의 하위키로 등록됩니다. 다른 쉘 익스텐션에서 보았듯이 txtfile 레지스트리 키에는 ShellEx 하위키가 있습니다. 여기에 다시 IconHandler 하위키가 놓입니다. 이 레지스트리 키의 기본 값은 우리가 작성한 쉘 익스텐션의 GUID입니다. 드롭 핸들러와 마찬가지로 특정한 한 형식의 파일에는 하나의 아이콘 핸들러만이 올 수 있습니다. IconHandler의 하위 키로 놓는 대신에 우리는 또한 DefaultIcon 레지스트리 키의 기본 값을 %1로 바꾸어 우리가 작성한 아이콘 핸들러가 호출되도록 만들 수 있습니다.
%1이라는 문자열을 지정하기 위해, 우리는 RGS 스크립트에서 %%1이라 적어야 함을 주목하시기 바랍니다. 왜냐하면 %는 대체 가능한 매개 변수(parameter)를 지시하는 데 사용되는 특수문자이기 때문입니다(예: %MODULE%).
DefaultIcon 값을 덮어쓰기하고 있는 사실은 중요한 문제를 야기합니다. 우리가 만들 쉘 익스텐션이 제거될 때 어떻게 하면 예전의 DefaultIcon 값을 되돌릴 수 있을까요? 답은, DllRegisterServer에서 DefaultIcon 기존의 값을 보관하고 DllUnregisterServer에서 이 값을 복원하는 것입니다.
우리는 “반드시” 이 작업을 수행하여 깨끗하게 제거되고 텍스트 파일 아이콘이 우리의 쉘 익스텐션이 설치되기 전으로 되돌려 놓아야 합니다.
등록과 등록 해제가 어떻게 이루어지는지는 첨부된 프로젝트의 소스 코드를 확인하시기 바랍니다. RGS 스크립트가 처리되기 위해 ATL이 호출되기 전에 백업이 수행되고 있음을 주목합니다. 반대로 작업을 수행(RGS 스트립트를 처리한 이후에 백업)한다면 우리가 백업할 기회를 잡기도 전에 DefaultIcon은 덮어쓰기 될 것이기 때문입니다.
원 저자의 저작권
(C) 2000-2006 Michael Dunn. 본 시리즈는 저작권을 갖는 저작물입니다. 필자는 이 글이 인터넷 상에서 퍼지는 것을 막을 수는 없음을 알고 있습니다. 어쨌든 필자는 말할 필요가 있습니다. 여러분이 이 글을 번역하고자 한다면, 필자에게 이메일을 보내어 알려 주시기 바랍니다. 누군가 번역하는 것을 막지는 않겠습니다만, 필자는 본 글에 링크를 게시하기 위해 번역되었음을 알고자 합니다.
본 글에 첨부된 예제 소스 코드는 공공 도메인(public domain)을 동반합니다. 필자는 이와 같이 함으로써 모두에게 소스 코드가 유용하게 쓰이길 바랍니다. (다만 본 글에 대해서는 공공 도메인을 원하지 않습니다. 왜냐하면 CodeProject에서만 열람 가능하도록 하는 것이 필자의 존재감과 CodeProject 사이트에 이익이 되기 때문입니다.) 여러분이 본 글에 첨부된 예제 프로그램을 여러분이 진행중인 프로젝트에 활용하고자 할 때 필자에게 이메일을 보내어 알려주신다면 매우 감사하겠습니다(이것은 단순히 필자의 소스 코드가 타인들에게 유용한지 그렇지 않은지를 알고 싶은 개인의 호기심 충족을 위해서입니다), 다만 이것은 필수 사항은 아닙니다. 여러분의 소스 코드에 필자에 대한 감사 표시를 해 주시면 좋습니다만, 이것도 필수 사항은 아닙니다.
포맷 ID가 우리의 쉘 익스텐션이 가지고 있는 GUID일 때 프로퍼티 ID는 0, 1 또는 2여야만 합니다. 왜냐하면 이러한 ID는 GetColumnInfo에서 이미 사용했습니다. 그 외 여러 가지 이유로 프로퍼티 ID가 우리가 설정한 범위를 벗어난 채로 메소드가 호출되었다면 쉘(shell)에게 그러한 데이터가 없음을 알리기 위해 S_FALSE를 반환합니다. 그러면 해당 열은 비어있는 채로 보여질 것입니다.
다음으로 우리는 포맷 ID는 FMTID_SummaryInformation과 비교합니다. 그 다음 프로퍼티 ID를 체크하여 해당 프로퍼티 ID가 우리가 제공하고 있는 것과 같은지 확인합니다.
다음으로 우리는 파일의 특성을 확인합니다. 이 파일이 사실은 디렉토리이거나 파일의 현재 상태가 ‘오프라인(즉, 다른 저장 미디어로 옮겨진 상태)’이라면 메소드를 끝냅니다. 또한 우리는 파일의 확장명도 검사합니다. .mp3가 아니라면 메소드를 종료합니다.
// ...
// 파일이 아닌 디렉토리에 의해 호출되었다면, 즉시 메소드를 종료할 수 있습니다.
// 또한 파일이 오프라인 상태일 때도 메소드르를 종료할 수 있습니다.
if (pscd->dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_OFFLINE))
return S_FALSE;
// 파일 확장명을 검사합니다. mp3 파일이 아니라면 메소드를 종료합니다.
if (wcsicmp(pscd->pwszExt, L".mp3"))
return S_FALSE;
// ...
여기까지 해서 우리는 내용을 읽어서 작업할 파일과 그렇지 않을 파일을 판별하였습니다. 앞서 선언한 ID3 태그 캐시는 이 때 사용할 것입니다. MSDN은 쉘(shell)이 파일 별로 GetItemData에 대한 호출을 그룹화한다고 하였습니다. 우리는 이 특성을 이용할 수 있고, 특정 파일에 대해 ID3 태그를 캐시(cache)할 수 있습니다. 그래서 우리는 연속된 함수 호출에 의해 각 파일들을 또 다시 읽어야 할 필요가 없습니다.
먼저 우리는 m_ID3Cache 멤버 변수로서 보관되는 캐시를 하나씩 순회하면서, 캐시된 파일 이름과 함수 호출로 전달된 파일 이름을 비교합니다. 캐시에서 해당 이름을 발견하였다면, 우리는 ID3 태그를 가져옵니다.
// ...
// 캐시에서 파일 이름을 찾습니다.
list_ID3Cache::const_iterator it, itEnd;
for (it = m_ID3Cache.begin(), itEnd = m_ID3Cache.end(); !bCacheHit && it != itEnd; it++) {
if (lstrcmpi(szFilename, it->sFilename.c_str()) == 0) {
CopyMemory(&rTag, &it->rTag, sizeof(CID3v1Tag));
bCacheHit = true;
}
}
// ...
루프 종료 후 bCacheHit가 false가 되면 우리는 해당 파일을 직접 읽어서 ID3 태그를 가지고 있는지 확인해야 합니다. 헬퍼(helper) 함수인 ReadTagFromFile은 파일로부터 이 128바이트를 읽기 위한 복잡한 작업들을 수행하고 성공하면 TRUE를, 그렇지 않으면 FALSE를 반환합니다.
알아둘 것은 ReadTagFromFile은 파일의 마지막 128바이트를 읽을 뿐 그것이 진짜 ID3 태그인지 여부와는 관계가 없습니다.
// ...
// 파일에 캐시가 없다면, 파일로부터 태그를 직접 읽습니다.
if (!bCacheHit) {
if (!ReadTagFromFile(szFilename, &rTag)) return S_FALSE;
// ...
이제 우리는 ID3 태그를 가졌습니다. 우리는 캐시의 사이즈를 체크하고 캐시가 5개의 항목으로 꽉 찼다면, 새롭게 얻은 태그를 추가하기 위하여 가장 오래된 항목을 지웁니다. (5개라는 숫자는 필자가 임의로 설정한 최대치입니다.) 우리는 새로운 CID3CacheEntry 객체를 생성하고 링크드 리스트에 이를 추가합니다.
// ...
// 우리는 5개의 태그가 캐시되도록 유지할 것입니다.
// 현재 캐시가 4개 이상의 항목을 가지고 있다면,
// 가장 오래된 항목 순으로 제거합니다.
while (m_ID3Cache.size() > 4)
m_ID3Cache.pop_back();
// 새롭게 얻은 ID3 태그를 캐시에 추가합니다.
CID3CacheEntry entry;
entry.sFilename = szFilename;
CopyMemory(&entry.rTag, &rTag, sizeof(CID3v1Tag));
m_ID3Cache.push_front(entry);
} // if(!bCacheHit)의 끝
// ...
다음 단계는 처음 세 바이트를 검사해서 ID3 태그의 시그니처인지 확인하여 ID3 태그가 실종하는지 검사합니다. 그렇지 않다면 우리는 즉시 메소드를 종료합니다.
// ...
// 시그니처를 검사하여 우리가 진짜로 ID3를 가지고 있는지 확인합니다.
if (StrCmpNA(rTag.szTag, "TAG", 3))
return S_FALSE;
// ...
다음은 쉘(shell)이 요청한 프로퍼티의 종류에 따라 ID3 태그의 필드를 읽습니다. 여기에서는 프로퍼티 아이디를 검사만 할 것입니다. 여기 예제가 있습니다. 제목 필드에 대하여 이렇게 작성합니다.
// ...
// 문자열을 구성합니다.
if (bUsingBuiltinCol) {
switch (pscid->pid) {
case 2: // 곡의 제목
CopyMemory(szField, rTag.szTitle, countof(rTag.szTitle));
szField[30] = '\0';
break;
// ...
}
// ...
szField 버퍼는 최대 31 글자까지 수용 가능함을 확인하시기 바랍니다. 이것은 본래 ID3v1 태그보다 1글자 더 많은 용량이지만, 문자열을 항상 NULL 문자로 끝나도록 해야 하므로 확보된 공간입니다. bUsingBuiltinCol 옵션은 FMTID/PID 쌍을 검사할 때보다 먼저 설정되었습니다. PID 하나만으로는 열을 식별하기에 충분하지 않기 때문에 이 플래그를 사용합니다. 왜냐하면 제목과 MP3 장르 열은 둘 다 PID 2번이기 때문입니다.
이 때, szField는 ID3 태그에서 읽은 문자열을 포함하고 있습니다. WinAmp의 ID3 태그 편집기는 문자열을 NULL 문자로 채우지 않고 공백 문자로 채웁니다. 때문에 우리는 원 문자열 뒤에 붙은 불필요한 문자들을 제거해야 합니다.
// ...
StrTrimA(szField, " ");
// ...
마지막으로 우리는 CComVariant 객체를 생성하고 szDisplayStr 문자열을 이 안에 보관해야 합니다. 그 다음 CComVariant::Detach를 호출하여 CComVariant 객체를 Windows 탐색기가 제공한 VARIANT로 복사해야 합니다.
컬럼 핸들러가 할 수 있는 또 다른 흥미로운 기능은 특정 파일 형식에 대해 인포팁을 수정할 수 있다는 것입니다. RGS 스크립트는 .mp3 파일에 대해 인포팁 내용을 수정할 수 있습니다. (수평 스크롤의 제한으로 인해 여러 줄에 걸쳐서 적었지만, 실제 RGS 스크립트에서는 한 줄에 적혀있습니다.)
HKCR {
NoRemove .mp3 {
val InfoTip = s 'prop:Type;Author;Title;Comment;
{AC146E80-3679-4BCA-9BE4-E36512573E6C},0;
{AC146E80-3679-4BCA-9BE4-E36512573E6C},1;
{AC146E80-3679-4BCA-9BE4-E36512573E6C},2;Size'
}
}
prop:으로 시작하는 문자열에 Author, Title 및 Comment 필드가 나타나고 있음을 주목하시기 바랍니다. 여러분이 .mp3 파일 위에 마우스 포인터를 올렸을 때 Windows 탐색기는 우리가 만든 쉘 익스텐션을 호출하여 해당 필드에 대한 문자열을 얻을 것입니다. 개발 문서에서는 우리가 추가한 필드는 인포팁에서도 또한 나타날 것이라고 말하고 있는데(우리가 GUID와 프로퍼티 아이디를 위와 같이 작성한 이유가 이것입니다), 하지만 필자는 Windows 2000에서는 작동되는 것을 확인할 수 없었고 운영체제에 내장된 프로퍼티만이 인포팁에 나타남을 확인할 수 있었습니다. 커스텀 인포팁은 다음과 같이 나타납니다.
Windows 2000에서는 개발 문서에 소개된 내용과 다르게 작동된다.
또한 이 쉘 익스텐션은 Windows XP에서는 작동하지 않을 수 있습니다. 왜냐하면 Windows XP는 새로운 파일 형식 레지스트리 키를 도입했기 때문입니다. 필자의 Windows XP에서 인포팁 정보는 HKCR\SystemFileAssociations\audio에 보관되어 있었습니다.
다음 단계에서 다룰 내용
다음 9 단계에서, 우리는 또 다른 쉘 익스텐션으로서 특정 파일 형식에 대해 아이콘을 수정할 수 있는 아이콘 핸들러에 대해 다루어 보겠습니다.
많은 독자 여러분께서 본 “입문자를 위한 가이드” 시리즈가 계속 되기를 원하십니다. 이번 단계에서 필자는 Windows Me 및 2000 이상의 운영체제에 대해 Windows 탐색기의 “자세히” 보기 화면에 표시되는 열을 추가하는 방법에 대해 다루어 보겠습니다. 이러한 유형의 쉘 익스텐션은 Windows NT 4.0, Windows 95 및 Windows 98에서는 지원되지 않습니다. 때문에 본 예제 프로젝트를 가지고 실습하고자 하실 경우 최신 버전의 운영체제를 사용해야만 합니다.
Visual C++ 7.0 또는 8.0 사용자는 이전과 같이 1 단계의 본 시리즈에 들어가며......를 참고하여 컴파일하기 전 몇 가지 설정을 변경해야 함을 기억하시기 바랍니다.
Windows Me와 2000는 “자세히”보기 모드에서 Windows 탐색기를 사용자화 할 수 있는 다양한 옵션들이 추가되었습니다. Windows 2000의 경우 여러분이 활성화시킬 수 있는 37가지의 새로운 열(column)들이 있습니다. 여러분은 두 가지 방법으로 이들 열(column)들을 나타나게 하고, 사라지게 할 수 있습니다. 먼저 열 헤더(column header)에 대해 마우스 오른쪽 버튼을 클릭하면 추가 및 제거할 수 있는 열의 종류가 컨텍스트 메뉴 형태로 몇 가지 나타납니다.
‘자세히’ 보기 모드에서 열 헤더 부분을 마우스 오른쪽 클릭해본다.
여러분이 [자세히...] 항목을 클릭한다면, Windows 탐색기는 사용 가능한 모든 열 항목들을 다이얼로그를 통해 보여줄 것이고, 이 중에서 선택할 수 있습니다.
‘열 설정’ 대화상자에서 표시할 열의 종류를 설정할 수 있다.
또한 Windows 탐색기는 이들 열 목록 속에 우리가 직접 정의한 것을 추가할 수 있도록 컬럼 핸들러 익스텐션(column handler extension)을 제공합니다.
이번 단계의 예제 프로젝트는 MP3 파일에 대한 컬럼 핸들러로서, .mp3 파일에 저장되는 ID3 태그(버전 1에 한함)로부터 다양한 필드를 읽어서 Windows 탐색기를 통해 사용자에게 보여줄 것입니다.
인터페이스 구현하기
여러분은 이미 프로젝트를 생성하는 과정에 대해 익숙해지셨을 것이므로 필자는 Visual C++ 마법사에 대한 내용은 생략하겠습니다. 새로운 ATL COM 프로젝트를 생성하고 그 이름을 MP3TagView로 정합니다. 또한 CMP3ColExt라는 이름의 C++ 구현 클래스를 생성합니다.
컬럼 핸들러는 단 하나의 인터페이스만 구현하면 됩니다. 바로 IColumnProvider입니다. 다른 쉘 익스텐션처럼 IShellExtInit나 IPersistFile을 통해 별도의 초기화 과정을 밟을 필요가 없습니다. 왜냐하면 컬럼 핸들러는 폴더에 대한 확장이고, 현재 선택된 파일에 대해서 어떤 작업을 할 필요가 없기 때문입니다. 물론 소정의 초기화 작업이 필요하기는 하지만, 이들은 IColumnProvider의 메소드를 통해 수행됩니다.
우리가 개발할 COM 개체에 IColumnProvider를 추가하기 위하여 MP3ColExt.h를 열고 아래 표시된 부분과 같이 내용을 추가합니다.
#include <comdef.h> // 새로 추가
#include <shlobj.h> // 새로 추가
#include <shlguid.h> // 새로 추가
/////////////////////////////////////////////////////////////////////////////
// CMP3ColExt
class CMP3ColExt :
public CComObjectRootEx<CComSingleThreadModel>,
public CComCoClass<CMP3ColExt, &CLSID_MP3ColExt>,
public IColumnProvider { // 새로 추가
BEGIN_COM_MAP(CMP3ColExt)
// 새로 추가
COM_INTERFACE_ENTRY_IID(IID_IColumnProvider, IColumnProvider)
END_COM_MAP()
public:
// 이 이하부터 모두 새로 추가
// IColumnProvider
STDMETHODIMP Initialize(LPCSHCOLUMNINIT psci) { return S_OK; }
STDMETHODIMP GetColumnInfo(DWORD dwIndex, SHCOLUMNINFO * psci);
STDMETHODIMP GetItemData(LPCSHCOLUMNID pscid, LPCSHCOLUMNDATA pscd, VARIANT * pvarData);
};
인터페이스 맵 부분에 COM_INTERFACE_ENTRY_IID라는 매크로가 적혀있는 것을 주목하시기 바랍니다. 이전까지 작성했던 쉘 익스텐션에서 COM_INTERFACE_ENTRY 매크로를 사용해 왔습니다. 이 때는 인터페이스가 __declspec(uuid) 구문으로 선언된 GUID를 가지고 있어야 합니다. 그러나 comdef.h의 경우 IColumnProvider에 대한 GUID를 선언하고 있지 않습니다. 그래서 우리는 이 인터페이스를 COM_INTERFACE_ENTRY를 써서 구현할 수 없습니다.
이 때 필요한 것이 COM_INTERFACE_ENTRY_IID입니다. 인터페이스의 이름과 이에 대한 IID를 명시적으로 지정해주기만 하면 됩니다. 이 방법 대신 클래스 선언 바로 직전에 다음과 같이 내용을 추가하면 기존처럼 COM_INTERFACE_ENTRY를 사용할 수도 있습니다.
우리는 또한 stdafx.h에서 몇 가지 설정을 바꾸어야 합니다. 우리는 Windows 2000의 기능들을 사용하고자 하므로 각종 선언과 원형들을 활성화시키기 위하여 다음과 같이 매크로 상수를 구성합니다.
#define WINVER 0x0500 // Windows 2000, 98 기능 사용
#define _WIN32_WINNT 0x0500 // Windows 2000 기능 사용
#define _WIN32_IE 0x0500 // Internet Explorer 5.0 이상의 기능 사용
위 매크로 상수들은 #include보다 먼저 등장해야 합니다.
초기화
IColumnProvider는 세 가지 메소드를 갖습니다. 그 중 하나는 Initialize로서 다음과 같은 원형을 갖습니다.
쉘(shell)은 우리에게 SHCOLUMNINIT 구조체를 전달해줄 것입니다. 이것은 Windows 탐색기에서 보여주고 있는 현재 폴더의 전체 경로를 포함하고 있습니다. 본 단계에서 구현할 예제에서는 필요한 정보가 아니므로 본 Initialize 메소드에서는 단순히 S_OK만 반환할 것입니다.
새로운 열(column)을 열거하기
우리가 만든 컬럼 핸들러가 등록되었음을 Windows 탐색기가 발견하였을 때, Windows 탐색기는 우리에게 어떤 열(column)들을 구현하고 있는지 물어보게 됩니다. 이 과정은 다음과 같은 원형을 가진 GetColumnInfo에서 처리됩니다.
dwIndex는 0부터 세는 카운터 값으로서, Windows 탐색기가 몇 번째 열에 대해 알고 싶어 하는지를 의미합니다.
psci는 SHCOLUMNINFO 형 구조체로서 열(column)에 대한 매개 변수(parameter)들로 우리가 내용을 채워야 하는 구조체입니다.
SHCOLUMNINFO의 첫 번째 멤버 변수는 또 다른 구조체인 SHCOLUMNID입니다. SHCOLUMNID는 GUID와 DWORD의 한 쌍으로 구성되어 있습니다. GUID의 경우 “포맷 아이디(format ID)”라고 부리고, DWORD의 경우 “프로퍼티 아이디(property ID)”라고 부릅니다. 이 한 쌍의 값은 한 시스템에서 특정 열(column)을 유일하게 식별할 수 있습니다. 예를 들어 저자(Author)와 같이 이미 존재하는 열(column)이라 해도 포맷 아이디와 프로퍼티 아이디가 미리 정의된 값으로 설정되어있다면 재사용할 수 있습니다.
쉘 익스텐션이 새로운 열(column)을 추가할 때, 유일한 것으로 보장되는 우리의 CLSID를 포맷 아이디로 지정한다면, 프로퍼티 아이디는 간단한 숫자로 지정할 수 있습니다.
본 단계에서 구현할 쉘 익스텐션은 두 가지 방법을 모두 사용해 볼 것입니다.
우리는 먼저 제작자(Author), 제목(Author) 및 주석(Comment) 열을 재사용할 것이고, MP3 앨범명(MP3 Album), MP3 연도(MP3 Year) 및 MP3 장르(MP3 Genre)라는 열을 새롭게 추가할 것입니다. 본 단계의 예제 프로젝트에서 GetColumnInfo는 다음과 같이 시작합니다.
STDMETHODIMP CMP3ColExt::GetColumnInfo(DWORD dwIndex, SHCOLUMNINFO * psci) {
// 우리는 최대 6개의 열을 제공할 것이기 때문에
// dwIndex가 6 이상이면 우리가 제공할 열을 모두 열거했음을 알리기 위해
// S_FALSE를 반환합니다.
if (dwIndex >= 6)
return S_FALSE;
// ...
dwIndex로 6 이상의 값이 전달되면 우리는 Windows 탐색기가 열에 대한 열거를 끝낼 수 있도록 S_FALSE를 반환합니다. 그렇지 않은 경우 우리는 SHCOLUMNINFO 구조체의 내용을 채워야 합니다. dwIndex의 값이 0, 1, 2인 경우 우리는 우리가 새롭게 추가할 열(column)에 대한 데이터를 반환할 것입니다. dwIndex의 값이 3, 4, 5인 경우 우리는 재사용하고자 하는 운영체제 내장 열(column)을 반환할 것입니다.
우리의 첫 번째 커스텀 열(custom column)인, ID3 태그에서 앨범 이름을 보여주는 열을 구성하는 예는 다음과 같습니다.
// ...
switch (dwIndex) {
case 0: // MP3 Album - separate column
psci->scid.fmtid = CLSID_MP3ColExt; // 포맷 ID로서 우리의 CLSID를 지정.
psci->scid.pid = 0; // 프로퍼티 ID로서 0번을 지정.
psci->vt = VT_LPSTR; // 이 열(column)의 데이터는 문자열임을 지정.
psci->fmt = LVCFMT_LEFT; // 문자열은 왼쪽 정렬로 출력되어야 함.
psci->csFlags = SHCOLSTATE_TYPE_STR; // 데이터는 문자열로 저장되어야 함.
psci->cChars = 32; // 기본 열 너비(단위: 문자)
wcsncpy(psci->wszTitle, L"MP3 Album", MAX_COLUMN_NAME_LEN);
wcsncpy(psci->wszDescription, L"Album name of an MP3", MAX_COLUMN_DESC_LEN);
break;
// ...
중요
이 글의 이전 버전에서는 csid.fmtid 멤버에 _Module.pguidVer라는 값을 저장하도록 작성했었는데 이는 근본적으로 틀린 내용이었습니다. 왜냐하면 GUID는 같은 버전의 ATL에서 빌드된 모든 바이너리에서 항상 똑같기 때문입니다. _Module.pguidVer를 사용하면서 같은 프로퍼티 아이디를 사용하는 서로 다른 두 개의 쉘 익스텐션이 있다면, 각 열 사이에 서로 엄청난 충돌이 발생할 것입니다.
우리는 포맷 아이디로서 본 단계에서 다루는 쉘 익스텐션의 GUID를 지정했고, 프로퍼티 아이디에는 열(column)의 번호를 지정했습니다. SHCOLUMNINIT 구조체의 vt 멤버는 우리가 Windows 탐색기에 반환할 데이터의 타입을 나타냅니다. VT_LPSTR은 C 언어 스타일의 문자열을 의미합니다.
fmt 멤버는 LVCFMT_로 시작하는 상수들 중 하나가 될 수 있으며 그 열(column)에서 문자열 정렬 방향을 지정합니다. 이 경우는 왼쪽 정렬로 하겠습니다.
csFlags 멤버는 열(column)에 대한 몇 가지 옵션입니다. 그러나 쉘(shell)은 모든 옵션들을 구현해 놓지는 않은 것으로 보입니다. 다음은 각 플래그(flag)에 대한 설명입니다.
SHCOLSTATE_TYPE_STR, SHCOLSTATE_TYPE_INT 및 SHCOLSTATE_TYPE_DATE
Windows 탐색기가 열을 기준으로 정렬할 때 해당 열의 데이터를 어떻게 처리해야 하는지를 지정합니다. 문자열, 정수 및 날짜/시각 중 하나가 될 수 있습니다.
SHCOLSTATE_ONBYDEFAULT
마이크로소프트 개발 지원 관계자인 Dave Anderson에 따르면 다음과 같은 용도를 갖습니다.
이 작동은 여러분의 쉘 탐색기(shell browser)의 설정에 따라 달라집니다. 여러분이 폴더 옵션에서 “각 폴더의 보기 옵션 기억”을 지정하였다면, 특정한 몇몇 쉘(shell)에서 열(column)이 보여지는 상태는 레지스트리로부터 가져오게 될 것입니다. 이 때는 SHCOLSTATE_ONBYDEFAULT 플래그가 아무 효과가 없습니다.
모든 폴더의 보기 설정을 리셋하는 것은, 여러분의 열도 기본 상태로 돌아가게 할 수 있습니다. 여러분은 이 설정을 Windows 탐색기 또는 제어판의 폴더 옵션 대화상자에서 할 수 있습니다.
SHCOLSTATE_SLOW
개발 문서에 따르면 이 옵션을 포함하는 것은 각 열의 데이터를 읽어오는데 시간이 걸림을 뜻합니다. 이 때 Windows 탐색기는 쉘(shell) 익스텐션을 하나 이상의 백그라운드 스레드에서 호출하여, Windows 탐색기 창 그 자체는 사용자로부터 원활하게 응답할 수 있도록 조치합니다. 필자가 테스트해 본 바, 이 옵션의 존재 여부에 따른 차이를 발견하지 못했습니다.
Windows 2000에서 Windows 탐색기는 쉘 익스텐션이 제공하는 열에 대한 데이터를 불러오는 데 하나의 스레드만을 사용할 뿐이었습니다.
Windows XP에서 Windows 탐색기는 몇 개의 서로 다른 스레드를 통해 작업하기는 하였으나, SHCOLSTATE_SLOW 옵션의 존재 여부에 따라 돌아가는 스레드의 개수의 차이는 발견하지 못했습니다.
SHCOLSTATE_SECONDARYUI
개발 문서에 따르면 이 옵션을 전달하는 것은 열(column)이 헤더 컨트롤의 컨텍스트 메뉴에 나타나는 것을 방지해준다고 합니다. 여러분이 이 옵션을 추가해주지 않는다면, 각 열의 제목에 대해 마우스 오른쪽 버튼을 클릭하여 나타나는 컨텍스트 메뉴에서 여러분이 추가하고 있는 열이 나타난다는 뜻입니다. 이 옵션이 포함되면, 컨텍스트 메뉴에는 곧바로 나타나지 않고 [자세히...] 버튼을 누른 후 대화상자를 통해서만 나타납니다.
SHCOLSTATE_HIDDEN
이 옵션을 전달하면 “열 설정(Column Settings)” 대화상자에서 열(column)이 나타나지 않습니다. 숨겨진 열을 활성화하는 방법은 없기 때문에 이 것은 열(column)을 사용할 수 없게 만듭니다.
cChars 멤버는 열(column) 이본 너비를 문자 수 단위로 지정합니다. 이 값을 열(column) 이름 길이의 최대치 및 여러분이 이 열을 통해 나타날 것으로 예상하는 가장 긴 문자열만큼 설정하시기 바랍니다. 또한 여러분은 전체 문자열을 충분하게 보여주도록 2, 3문자의 여유를 더 추가하여야 합니다. 이 여유분을 넣지 않으면 열의 기본 너비는 충분히 넓지 않을 수 있고 일부 텍스트가 잘려서 표시될 수 있습니다.
마지막 두 멤버는 헤더 컨트롤(header control)에서 보여질 열의 이름을 보관하는 유니코드 문자열 및 열에 대한 설명을 보관하는 유니코드 문자열입니다. 현재는 이 열을 설명하는 문자열이 사용되지 않기 때문에 사용자는 이를 보는 일이 없습니다.
제1열과 제2열도 비슷합니다. 그러나 제1열은 데이터 타입과 정렬 방법에서 차이가 있습니다. 이 열은 연도를 나타내기 때문인데, 어떻게 정의되는지는 아래의 코드에서 확인 가능합니다.
// ...
case 1: // MP3 year - separate column
psci->scid.fmtid = CLSID_MP3ColExt;
psci->scid.pid = 1;
psci->vt = VT_LPSTR; // 문자열의 형태로 데이터를 반환할 것입니다.
psci->fmt = LVCFMT_RIGHT; // 문자열은 오른쪽 정렬로 나타날 것입니다.
psci->csFlags = SHCOLSTATE_TYPE_INT; // 정수 형태로 저장되어야 합니다.
psci->cChars = 6; // 열의 기본 폭을 지정합니다.
wcsncpy(psci->wszTitle, L"MP3 Year", MAX_COLUMN_NAME_LEN);
wcsncpy(psci->wszDescription, L"Year of an MP3", MAX_COLUMN_DESC_LEN);
break;
// ...
vt 멤버 변수가 VT_LPSTR로 되어 있음에 주목하시기 바랍니다. 이것은 우리가 Windows 탐색기에게 문자열을 전달할 것임을 의미합니다. 하지만 csFlags 멤버 변수는 SHCOLSTATE_TYPE_INT로 되어 있습니다. 이것은 Windows 탐색기가 우리가 전달한 데이터들을 정렬할 때 가나다 순이 아니라, 숫자로서 크기 비교를 하라는 뜻입니다. 물론 처음부터 우리가 Windows 탐색기에 문자열이 아닌 정수 형식으로 데이터를 전달할 수도 있습니다. 그러나 .mp3 파일의 ID3 태그가 발행 연도 항목을 정수가 아닌 문자열 형식으로 보관하고 있기 때문에, 위와 같이 열 정의를 하면 우리가 직접 문자열을 정수로 변환하지 않아도 됩니다.
dwIndex가 3, 4, 5일 때 우리는 재사용하고자 하는 운영체제 내장 열(column)을 반환합니다. 제3열은 저작자(Author) 열(column)로서 ID3의 아티스트(Artist) 정보를 보여줄 것입니다.
// ...
case 3: // MP3 artist - reusing the built-in Author column
psci->scid.fmtid = FMTID_SummaryInformation; // 미리 정의된 FMTID
psci->scid.pid = 4; // 미리 정의된 author 열
psci->vt = VT_LPSTR; // 데이터를 문자열 형태로 반환할 것입니다.
psci->fmt = LVCFMT_LEFT; // 문자열은 왼쪽 정렬될 것입니다.
psci->csFlags = SHCOLSTATE_TYPE_STR; // 데이터는 문자열로서 정렬됩니다.
psci->cChars = 32; // 열의 기본 너비(단위: 문자)
break;
// ...
FMTID_SummaryInformation은 미리 정의된 기호입니다. 저자(author) 필드는 MSDN 문서에 따르면 4번으로 등록되어 있습니다. 전체 목록은 “The Summary Information Property Set”을 참고하시기 바랍니다. 운영체제에서 미리 정의해 놓은 열을 재사용하고자 할 때, 우리는 열의 제목이나 설명을 반환하지 않습니다. 왜냐하면 쉘(shell)이 이미 이것을 다룰 것이기 때문입니다.
마지막으로 switch 구문의 끝나고 우리는 SHCOLUMNINFO 구조체를 모두 채웠음을 알리기 위하여 S_OK를 반환합니다.
열에 데이터 출력하기
IColumnProvider의 마지막 메소드는 GetItemData입니다. 이것은 파일에 대한 데이터를 얻어서 출력하기 위해 Windows 탐색기에 의해 호출되는 메소드입니다. 이 메소드의 원형은 다음과 같습니다.
SHCOLUMNID 구조체는 Windows 탐색기가 어떤 열의 데이터를 필요로 하는지를 알려줍니다. 또한 이 구조체는 우리가 GetColumnInfo 메소드를 통해 Windows 탐색기에게 전달해 준 정보도 포함하고 있습니다.
SHCOLUMNDATA 구조체는 파일이나 디렉토리에 대한 자세한 정보를 경로와 함께 포함하고 있습니다. 우리는 이 정보를 해당 파일 또는 디렉토리에 대한 데이터를 제공할 것인지 여부를 결정하기 위해 사용할 수 있습니다.
pvarData는 VARIANT 구조체에 대한 포인터로서, Windows 탐색기를 통해 우리가 보여주고자 하는 실질적인 데이터를 보관할 것입니다. VARIANT라는 자료형은 Visual Basic과 같은 스크립트 언어들이 갖는 loosely-typed 변수의 C 언어 버전입니다. 이 구조체는 두 가지 부분으로 구성되어 있는데, 하나는 타입이고 하나는 데이터입니다. ATL은 VARIANT를 초기화하고 값을 설정하는 것과 관련해 편리하게 다룰 수 있는 CComVariant라는 클래스를 제공하고 있습니다.
쉘 버전 4.71 및 그 이후 버전에서 여러분은 바탕화면 또는 파일 시스템의 디렉토리를 보여주는 아무 Windows 탐색기 창에서 마우스 오른쪽 클릭을 했을 때 나타나는 컨텍스트 메뉴를 수정할 수 있습니다. 이러한 쉘 익스텐션을 작성하는 방법은 여타 컨텍스트 메뉴 익스텐션을 작성하는 것과 비슷합니다. 다만 두 가지가 다릅니다.
- IShellExtInit::Initialize 메소드의 매개 변수(parameter)가 다르게 사용됩니다.
- 쉘 익스텐션이 다른 레지스트리 키에 등록됩니다.
필자는 이 글에서 본 쉘 익스텐션을 작성하는 모든 과정을 설명하지는 않겠습니다. 전체적인 과정은 첨부한 예제 프로젝트를 참고하시기 바랍니다.
IShellExtInit::Initialize에서 나타나는 차이
Initialize는 pidlFolder 매개 변수(parameter)를 가지고 있는데, 지금까지 우리는 이를 무시하여 왔습니다. 왜냐하면 이것은 줄곧 NULL이었기 때문입니다. 드디어 이제부터 이 매개 변수(parameter)는 의미 있는 용도를 갖게 됩니다! 이 매개 변수(parameter)는 마우스 오른쪽 클릭이 발생한 디렉토리의 PIDL입니다. 두 번째 매개 변수(parameter)인 IDataObject 인터페이스형 포인터는 NULL이 됩니다. 왜냐하면 선택된 파일이 없기 때문입니다.
다음은 예제 프로젝트에서 Initialize 메소드의 구현을 가져온 것입니다.
STDMETHODIMP CBkgndCtxMenuExt::Initialize(LPCITEMIDLIST pidlFolder, LPDATAOBJECT pDO, HKEY hkeyProgID) {
// SHGetPathFromIDList API를 사용하여
// PIDL로부터 일반적인 디렉토리 경로를 구합니다.
return SHGetPathFromIDList(pidlFolder, m_szDirClickedIn) ? S_OK : E_INVALIDARG;
}
SHGetPathFromIDList 함수는 폴더의 PIDL을 받아서 문자열 형태의 전체 경로를 반환합니다. 이 경로는 나중에 사용하기 위해 멤버 변수에 보관해 둡니다. 함수의 성공 및 실패 여부에 따라 BOOL 값이 반환됩니다.
IShellExtInit::GetCommandString에서 나타나는 차이
Windows XP부터 Windows 탐색기는 GetCommandString에서 반환되는 동사(verb)의 이름을 검사합니다. 그리고 서로 일치하는 동사를 가진 메뉴 항목을 삭제합니다. 우리가 구현할 GetCommandString 메소드도 GCS_VERB를 포함하고 있는지 확인해야 합니다. 그리고 각 메뉴 항목에 따라 다른 동사(verb)를 반환해야 합니다.
STDMETHODIMP CBkgndCtxMenuExt::GetCommandString(UINT uCmd, UINT uFlags, UINT * puReserved, LPSTR pszName, UINT cchMax) {
USES_CONVERSION;
static LPCTSTR szItem0Verb = _T("CPBkgndExt0");
static LPCTSTR szItem1Verb = _T("CPBkgndExt1");
// 우리는 두 개의 메뉴 항목을 추가했으므로 idCmd가 0 또는 1이어야만 합니다.
if ( uCmd > 1 )
return E_INVALIDARG;
// ‘플라이-바이(fly-by)’ 도움말 문자열 반환하는 내용은 생략합니다.
// 새로 추가하고 있는 두 메뉴 항목 각각에 대한 동사(verb)를 반환합니다.
if (uFlags == GCS_VERBA)
lstrcpynA(pszName, T2CA((uCmd == 0) ? szItem0Verb : szItem1Verb), cchMax);
else if (uFlags == GCS_VERBW)
lstrcpynW((LPWSTR) pszName, T2CW((uCmd == 0) ? szItem0Verb : szItem1Verb), cchMax);
return S_OK;
}
이러한 유형의 쉘 익스텐션은 HKCR\Directory\Background\ShellEx\ContextMenuHandlers에 등록됩니다. RGS 스크립트는 다음과 같이 구성합니다.
위와 같은 두 가지의 차이에도 불구하고, 쉘 익스텐션은 다른 컨텍스트 메뉴 익스텐션과 동일하게 작동합니다. 그렇지만 IContextMenu::QueryContextMenu에서 한 가지 알아둘 사항이 있습니다. Windows 98 및 Windows 2000에서 uIndex 매개 변수(parameter)는 항상 -1 다시 말하면 0xFFFFFFFF인 듯 합니다. InsertMenu에 인덱스 값으로 -1을 전달하는 것은 새로 추가될 메뉴 항목이 컨텍스트 메뉴의 최하단에 추가됨을 뜻합니다. 그러나 여러분이 uIndex의 값을 1 증가시켜서 영(0)으로 만든다면, InsertMenu에 0이 된 uIndex를 전달하게 되는데, 이는 컨텍스트 메뉴의 최상단에 새로운 메뉴 항목이 추가됨을 의미합니다. 예제 프로젝트의 QueryContextMenu를 참고하셔서 어떻게 메뉴 항목이 적절한 위치에 추가되는지를 확인하시기 바랍니다.
수정된 컨텍스트 메뉴는 다음과 같이 생겼습니다. 두 개의 메뉴 항목이 컨텍스트 메뉴 하단에 추가되었습니다. 필자의 짧은 식견으로 보았을 때, 이와 같은 방식으로 컨텍스트 메뉴의 끝 부분에 메뉴 항목들을 추가하는 것은 사용자 편의성에 중대한 문제가 있을 것입니다.
사용자가 [등록 정보] 또는 [속성] 항목을 선택하고자 할 때, 습관적으로 마우스 오른쪽 클릭을 하여 맨 마지막 메뉴 항목을 클릭합니다. 그런데 우리가 추가하고자 하는 메뉴 항목이 [등록 정보] 또는 [속성] 항목보다 더 아래에 있게 되면, 사용자들이 익숙하게 써오던 쉘 사용을 방해하는 것이 되고, 사용자들은 ‘깊은 빡침’을 느끼며 항의성 이메일을 보내올 수 있습니다. ^^;;
컨텍스트 메뉴의 최하단에 항목을 추가하는 행위는 사용자의 편의성을 해칠 수 있다.
다음 단계에서 다룰 내용
다음 8 단계에서는 Windows 탐색기에서 “자세히” 보기 모드를 선택하였을 때 나타나는 열(column)을 사용자화할 수 있는 컬럼 핸들러 익스텐션(column handler extension)에 대해 다루어 보겠습니다.
이제 됐습니다. 지금까지 보아왔던 소스 코드에 여러분은 지루함을 느끼셨으리라 충분히 이해합니다. 이제 진짜로 새롭고 흥미로운 작업을 하게 될 것입니다! IContextMenu2 인터페이스와 IContextMenu3 인터페이스에서 추가된 두 개의 메소드가 있습니다. 이들은 단순히 이 프로젝트 내에서 결과적으로 메시지 핸들러를 호출하게 될 헬퍼(helper) 함수를 호출하는 것에 지나지 않습니다.
이와 같이 호출에 호출을 거듭하게 코드를 작성한 것은 결국 같은 역할을 하는 메소드를 버전이 다르다고(하나는 IContextMenu2이고 다른 하나는 IContextMenu3) 두 번씩 작성하게 만드는 것을 방지할 수 있기 때문입니다. LRESULT * 매개 변수(parameter)와 관련해서 HandleMenuMsg2 메소드에는 다소 이상한 것이 있습니다. 아래 소스코드의 주석 부분에서 설명합니다.
STDMETHODIMP CBmpCtxMenuExt::HandleMenuMsg(UINT uMsg, WPARAM wParam, LPARAM lParam) {
AFX_MANAGE_STATE(AfxGetStaticModuleState()); // MFC 초기화
// res는 더미(dummy) LRESULT 변수입니다. 즉 실제로 사용되지는 않습니다.
// IContextMenu2::HandleMenuMsg()는 값을 반환할 방법도 제공하지 않습니다.
// 그럼에도 res가 필요한 것은 MenuMessageHandler가 호출될 때
// IContextMenu2 또는 IContextMenu3 인터페이스에서 호출했는지에 관계없이
// 소스 코드 수준에서 동일한 함수 호출 인터페이스를 유지하기 위함입니다.
LRESULT res;
return MenuMessageHandler(uMsg, wParam, lParam, &res);
}
STDMETHODIMP CBmpCtxMenuExt::HandleMenuMsg2(UINT uMsg, WPARAM wParam, LPARAM lParam, LRESULT * pResult) {
AFX_MANAGE_STATE(AfxGetStaticModuleState()); // MFC 초기화
// 반환 값이 없는 메시지의 경우 pResult는 NULL입니다.
// 이것이 NULL이 되면, 필자는 더미(dummy) LRESULT형 변수를 만들 것입니다.
// 그러면 MenuMessageHandler의 pResult는 항상 유효한 주소만을 가리킬 것입니다.
if (pResult == NULL) {
LRESULT res;
return MenuMessageHandler(uMsg, wParam, lParam, &res);
} else {
return MenuMessageHandler(uMsg, wParam, lParam, pResult);
}
}
MenuMessageHandler는 WM_MEASUREITEM 및 WM_DRAWITEM을 각각의 메시지 핸들러에게 전달만 할 것입니다.
이전에도 언급했듯이 개발 문서에 따르면 쉘(shell)은 쉘 익스텐션에게 WM_INITMENUPOPUP 및 WM_MENUCHAR에 대해 처리할 수 있도록 해주어야 한다고 적혀있지만, 필자는 테스트하는 동안 그러한 메시지가 전달되는 것을 확인할 수 없었습니다.
WM_MEASUREITEM 메시지 처리하기
쉘(shell)은 우리가 만들고 있는 쉘 익스텐션에게 WM_MEASUREITEM 메시지를 보내서 이 메뉴 항목의 디멘션(dimension)을 요청합니다. 우리는 우리가 만든 메뉴 항목에 의해 호출되었는지를 확인하는 것부터 시작합니다. 검사가 통과하면, 우리는 비트맵 이미지의 디멘션(dimension, 번역자 주: 정사각형 개체에 대한 가로 길이와 세로 길이)을 가져옵니다. 그리고 메뉴 항목의 전체적인 크기를 계산합니다.
먼저 비트맵 이미지의 크기를 가져오는 부분입니다.
HRESULT CBmpCtxMenuExt::OnMeasureItem(MEASUREITEMSTRUCT * pmis, LRESULT * pResult) {
BITMAP bm;
LONG lThumbWidth, lThumbHeight;
// 우리가 만든 메뉴 항목 때문에 호출된 것이 아니라면 아무 작업을 하지 않습니다.
if (m_uOurItemID != pmis->itemID)
return S_OK;
m_bmp.GetBitmap(&bm);
m_lBmpWidth = bm.bmWidth;
m_lBmpHeight = bm.bmHeight;
// ...
그 다음 우리는 썸네일의 크기를 계산합니다. 그것으로부터 컨텍스트 메뉴 항목의 전체적인 크기를 결정합니다. 비트맵 이미지의 크기가 최대 썸네일 크기보다 작다면(이 예제 프로젝트에서는 64 * 64가 최대 크기입니다), 비트맵 이미지는 있는 그대로 메뉴에 그려질 것입니다. 그렇지 않으면 비트맵 이미지는 64 * 64 크기에 맞추어 그려질 것입니다. 비트맵 이미지의 크기를 조정해서 그린다는 것은 원래의 비트맵 이미지를 다소 왜곡(번역자 주: 가로와 세로 비율이 틀어져서 길쭉하거나 넓적하게 보여짐)하여 표현할 수 있겠으나, 썸네일 이미지를 보기 좋게 조정하는 것은 여러분들의 과제로 남기겠습니다.
이제 우리는 메뉴 항목의 크기를 결정했으므로, 이 값을 우리가 메시지와 함께 받았던 MENUITEMSTRUCT 구조체에 보관합니다. Windows 탐색기는 우리가 추가하는 메뉴 항목을 위해 충분한 공간을 확보해줄 것입니다.
// ...
pmis->itemWidth = m_lItemWidth;
pmis->itemHeight = m_lItemHeight;
*pResult = TRUE; // 이제 우리는 메시지를 처리했습니다.
return S_OK;
}
WM_DRAWITEM 메시지 처리하기
우리가 WM_DRAWITEM 메시지를 전달받았을 때, Windows 탐색기는 우리가 실제로 메뉴 항목을 그릴 수 있도록 요청합니다. 우리는 썸네일 주변으로 3D 테두리를 그리기 위한 RECT를 계산하는 것으로 시작합니다. 이 때 RECT는 WM_MEASUREITEM 핸들러를 처리할 때 반환했던 크기와 반드시 같아야 할 필요는 없습니다. 왜냐하면 메뉴 항목은 컨텍스트 메뉴 내 다른 항목들이 더 넓을 경우 함께 넓어지기 때문입니다.
HRESULT CBmpCtxMenuExt::OnDrawItem(DRAWITEMSTRUCT * pdis, LRESULT * pResult) {
CDC dcBmpSrc;
CDC* pdcMenu = CDC::FromHandle(pdis->hDC);
CRect rcItem(pdis->rcItem); // 메뉴 항목에 대한 RECT
CRect rcDraw; // 그리기 작업을 할 RECT
// 우리가 추가한 메뉴 항목에 의해 호출되었는지를 검사합니다.
if (m_uOurItemID != pdis->itemID)
return S_OK;
// rcDraw는 처음에는 WM_MEASUREITEM 이벤트를 처리하면서 얻게 된 크기에 따라
// 설정될 것입니다. 그 후 소스 코드가 진행되면서 축소될 것입니다.
rcDraw.left = rcItem.CenterPoint().x - m_lItemWidth/2;
rcDraw.top = rcItem.CenterPoint().y - m_lItemHeight/2;
rcDraw.right = rcDraw.left + m_lItemWidth;
rcDraw.bottom = rcDraw.top + m_lItemHeight;
// 썸네일 주변 패딩(padding) 공간에 따라 rcDraw 사각 영역을 축소시킵니다.
rcDraw.DeflateRect(m_lMenuItemSpacing, m_lMenuItemSpacing);
그림을 출력하기 위한 첫 번째 단계로 메뉴 항목의 바탕에 색을 칠합니다.
DRAWITEMSTRUCT 구조체의 itemState 멤버는 우리가 추가한 메뉴 항목에 포커스가 주어졌는지, 그렇지 않은지 여부를 나타냅니다. 이에 따라 우리는 바탕이 될 색을 선택하면 됩니다.
// ...
// 메뉴 항목의 바탕을 특정 색으로 칠합니다.
if (pdis->itemState & ODS_SELECTED)
pdcMenu->FillSolidRect(rcItem, GetSysColor(COLOR_HIGHLIGHT));
else
pdcMenu->FillSolidRect(rcItem, GetSysColor(COLOR_MENU));
// ...
그 다음으로 우리는 썸네일 이미지가 메뉴 속으로 움푹 들어간 것처럼 보이도록 ‘선큰(sunken)’ 테두리를 그립니다.
// ...
// 선큰(sunken) 3D 테두리를 그립니다.
for (int i = 1; i <= m_l3DBorderWidth; i++) {
pdcMenu->Draw3dRect(rcDraw, GetSysColor(COLOR_3DDKSHADOW), GetSysColor(COLOR_3DHILIGHT));
rcDraw.DeflateRect(1, 1);
}
// ...
마지막으로 썸네일 이미지 그 자체를 그릴 차례입니다. 필자는 StretchBlt를 사용하여 간단하게 구현해 보았습니다. 결과는 그다지 예쁘지는 않지만, 그래도 필자의 목표인 코드를 간단하게 작성하는 것은 성공했습니다.
// ...
// 새로운 DC를 생성하고 여기에 원본 비트맵을 선택합니다.
CBitmap* pOldBmp;
dcBmpSrc.CreateCompatibleDC(&dc);
pOldBmp = dcBmpSrc.SelectObject(&m_bmp);
// 비트맵 이미지를 메뉴 DC에 입힙니다.
pdcMenu->StretchBlt(rcDraw.left, rcDraw.top, rcDraw.Width(), rcDraw.Height(), &dcBmpSrc, 0, 0, m_lBmpWidth, m_lBmpHeight, SRCCOPY);
dcBmpSrc.SelectObject(pOldBmp);
*pResult = TRUE; // 우리는 이 메시지를 처리했습니다.
return S_OK;
}
실제 쉘 익스텐션에서는 마우스가 지나갈 때마다 그림이 깜박거리지 않도록, 깜박거림이 없는 클래스를 사용하는 것이 좋습니다.
여기 메뉴의 작동 결과에 대한 스크린 샷이 있습니다. 그림이 그려진 메뉴가 마우스 포인터를 지나갈 때마다 다음과 같이 보여집니다.
Windows 2000에서 포커스(focus)되지 않은 컨텍스트 메뉴 항목.Windows 2000에서 포커스(focus)된 컨텍스트 메뉴 항목.
그리고 버전 4.0의 쉘에서 실행할 때의 모습입니다. 메뉴의 선택 여부에 따라 색이 반전되는데 이는 다소 ‘후져’보입니다.
Windows NT 4.0에서 포커스(focus)되지 않은 컨텍스트 메뉴 항목.Windows NT 4.0에서 포커스(focus)된 컨텍스트 메뉴 항목.
쉘 익스텐션을 등록하기
우리가 만든 비트맵 뷰어를 등록하는 것은 여타 컨텍스트 메뉴 익스텐션을 등록하는 것과 다르지 않습니다. 등록을 위한 RGS 스크립트는 다음과 같이 생겼습니다.
염두에 두실 것은 Paint.Picture라는 파일 유형은 여기서 하드코드(hard-coded)되었다는 것입니다. .bmp 파일에 대해 그림판을 기본 연결 프로그램으로 지정하지 않았다면, Paint.Picture라는 문자열을 HKCR\.bmp이 가리키는 레지스트리 키의 이름으로 바꾸어야 합니다. 말할 것도 없이 좀 더 생산적인 코드에서는 DllRegisterServer에서 이 작업을 수행합니다. 여러분은 Paint.Picture라는 레지스트리 키의 이름이 현재 컴퓨터 설정에 적절한지를 검사할 수 있습니다. 이 주제에 대해서는 1 단계에서 설명했습니다.
하나는 컨텍스트 메뉴 익스텐션에서 메뉴 항목에 그림을 출력하는 것이고, 다른 하나는 디렉토리 윈도우의 빈 공간에서 마우스 오른쪽 클릭을 하였을 때 나타나는 컨텍스트 메뉴 항목을 확장해 보는 것입니다. 이번 단계를 읽기 전에 여러분은 1 단계와 2 단계를 읽고 컨텍스트 메뉴 익스텐션에 대한 배경지식을 가지고 있어야 합니다.
Visual C++ 7.0 또는 8.0 사용자는 이전과 같이 1 단계의 본 시리즈에 들어가며......를 참고하여 컴파일하기 전 몇 가지 설정을 변경해야 함을 기억하시기 바랍니다.
첫 번째 쉘 익스텐션: 컨텍스트 메뉴 항목에 그림을 출력하기
이번 절에서 필자는 메뉴 항목에 그림을 추가하기 위해 필요한 추가적인 작업에 대해 다루어 보겠습니다.
이번 쉘 익스텐션에서는 그림을 출력할 것이므로, 생동감 있어 보일 것입니다. 필자는 컨텍스트 메뉴를 통해 비트맵 파일의 썸네일을 보여주는 기능이 있는 PicaView라는 프로그램을 똑같이 구현해 보겠습니다. (참고: PicaView는 ACD Systems에서 개발된 프로그램입니다. 현재는 단종되었습니다.) PicaView에 의해 확장된 컨텍스트 메뉴는 이렇게 생겼습니다.
컨텍스트 메뉴에 출력되는 그림.
우리가 다룰 쉘 익스텐션도 소스 코드를 최대한 쉽게 유지하면서, 이와 같이 비트맵 파일에 대한 썸네일을 컨텍스트 메뉴가 표시되도록 하겠습니다. 다만 필자는 색채를 정확하게 재현하는 부분까지는 고려하지 않겠습니다. 그러한 부분은 독자 여러분의 몫으로 남겨두겠습니다. ^^;;
초기화 인터페이스
여러분은 이제 이 단계에 능숙해졌을 것입니다. Visual C++ 마법사에 대한 설명은 생략하고, 곧바로 BmpViewExt라는 이름의 MFC가 지원되는 ATL COM 프로젝트를 생성하고, CBmpCtxMenuExt라는 이름의 C++ 클래스를 생성합니다.
지금까지 해 보았던 컨텍스트 메뉴 익스텐션과 마찬가지로 이번 쉘 익스텐션도 IShellExtInit 인터페이스를 구현합니다. COM 객체에 IShellExtInit를 추가하기 위하여 BmpCtxMenuExt.h를 열고 아래 표시한 부분과 같이 내용을 추가합니다. 물론 메뉴 항목을 구현할 때 사용하게 될 몇 가지 멤버 변수도 필요합니다.
#include <comdef.h> // 새로 추가
/////////////////////////////////////////////////////////////////////////////
// CBmpCtxMenuExt
class CBmpCtxMenuExt :
public CComObjectRootEx<CComSingleThreadModel>,
public CComCoClass<CBmpCtxMenuExt, &CLSID_BmpCtxMenuExt>,
public IShellExtInit { // 새로 추가
BEGIN_COM_MAP(CBmpCtxMenuExt)
COM_INTERFACE_ENTRY(IShellExtInit)
END_COM_MAP()
// 이 이하로 전부 새로 추가
public:
// IShellExtInit
STDMETHODIMP Initialize(LPCITEMIDLIST, LPDATAOBJECT, HKEY);
protected:
TCHAR m_szFile[MAX_PATH];
CBitmap m_bmp;
UINT m_uOurItemID;
LONG m_lItemWidth, m_lItemHeight;
LONG m_lBmpWidth, m_lBmpHeight;
static const LONG m_lMaxThumbnailSize;
static const LONG m_l3DBorderWidth;
static const LONG m_lMenuItemSpacing;
static const LONG m_lTotalBorderSpace;
// 메뉴와 관련된 메시지를 처리하기 위한 헬퍼(helper) 메소드
STDMETHODIMP MenuMessageHandler(UINT, WPARAM, LPARAM, LRESULT *);
STDMETHODIMP OnMeasureItem(MEASUREITEMSTRUCT *, LRESULT *);
STDMETHODIMP OnDrawItem(DRAWITEMSTRUCT *, LRESULT *);
};
IShellExtInit::Initialize에서 수행할 작업은, 마우스 오른쪽 버튼이 눌림 파일의 이름을 가져오는 것입니다. 그리고 그 확장명이 .bmp 이면 썸네일을 생성합니다.
BmpCtxMenuExt.cpp 파일에서 정적 변수에 대한 선언을 추가합니다. 이들은 썸네일의 비율과 그 테두리를 제어하게 될 것입니다. 이들이 이미지를 변화시키는 것에 대한 부담은 잠시 접어두고, 이들이 최종적으로 컨텍스트 메뉴에서 어떻게 나타나는지에 대해 직접 보도록 하시기 바랍니다.
const LONG CBmpCtxMenuExt::m_lMaxThumbnailSize = 64;
const LONG CBmpCtxMenuExt::m_l3DBorderWidth = 2;
const LONG CBmpCtxMenuExt::m_lMenuItemSpacing = 4;
const LONG CBmpCtxMenuExt::m_lTotalBorderSpace = 2 * (m_lMenuItemSpacing + m_l3DBorderWidth);
다음은 위 상수에 대한 의미입니다.
- m_lMaxThumbnailSize: 비트맵 이미지의 크기가 이 상수보다 크면, 가로 및 세로의 길이가 m_lMaxThumbnailSize 픽셀인 정사각형 영역에 맞추어 이미지는 축소시킬 것입니다. 비트맵 이미지가 이보다 작을 경우 변화를 주지 않고 그대로 보일 것입니다.
- m_l3DBorderWidth: 썸네일 주변으로 보여줄 3D 테두리의 두께입니다. 단위는 픽셀(pixel)입니다.
- m_lMenuItemSpacing: 3D 테두리 주변으로 둘 여백의 너비로서 단위는 픽셀(pixel)입니다. 이 값에 따라 썸네일과 그 주변의 메뉴 항목 사이를 띄워집니다.
또한 IShellExtInit::Initialize 메소드를 다음과 같이 정의합니다.
STDMETHODIMP CBmpCtxMenuExt::Initialize(LPCITEMIDLIST pidlFolder, LPDATAOBJECT pDO, HKEY hkeyProgID) {
AFX_MANAGE_STATE(AfxGetStaticModuleState());
COleDataObject dataobj;
HGLOBAL hglobal;
HDROP hdrop;
bool bOK = false;
dataobj.Attach(pDO, FALSE);
// 첫 번째로 선택된 파일의 이름을 가져옵니다.
// 필자는 파일의 확장명이 .bmp인지를 확인하고 보관할 것입니다.
hglobal = dataobj.GetGlobalData(CF_HDROP);
if (hglobal == NULL)
return E_INVALIDARG;
hdrop = (HDROP)GlobalLock(hglobal);
if (hdrop == NULL)
return E_INVALIDARG;
// 선택된 파일들 중 첫 번째 파일의 이름을 가져옵니다.
if (DragQueryFile(hdrop, 0, m_szFile, MAX_PATH)) {
// 파일의 확장명이 .bmp인가?
if (PathMatchSpec(m_szFile, _T("*.bmp"))) {
// 비트맵을 로드하여 CBitmap 객체에 연결시킵니다.
HBITMAP hbm = (HBITMAP)LoadImage(NULL, m_szFile, IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE);
if (hbm != NULL) {
// 비트맵을 로드하는데 성공했으므로 CBitmap 객체에 전달합니다.
m_bmp.Attach(hbm);
bOK = true;
}
}
}
GlobalUnlock(hglobal);
return bOK ? S_OK : E_FAIL;
}
비교적 단순한 작업을 마쳤습니다. 우리는 다음에 사용할 비트맵을 파일로부터 로드하였고, CBitmap 객체에 연결시켰습니다.
컨텍스트 메뉴와 상호작용하기
이전과 같이 우리는 세 가지 IContextMenu 메소드에서 필요한 작업을 수행합니다. 그리고 우리는 QueryContextMenu 메소드에서 컨텍스트 메뉴에 항목들을 새로 추가합니다. 먼저 우리는 쉘의 버전을 체크합니다. 버전이 4.71 이상일 경우 우리는 컨텍스트 메뉴 항목에 그림을 출력합니다. 그렇지 않다면 우리는 비트맵을 보여주는 메뉴 항목을 추가합니다. 후자의 경우 우리가 해야 할 것은 항목을 추가하는 것이 전부입니다.
먼저 쉘 버전을 체크해야 합니다. 쉘의 버전을 얻기 위하여 쉘이 내보내는 함수인 DllGetVersion을 호출합니다. 그런 이름의 함수를 쉘이 내보내고 있지 않다면, 그 쉘의 버전은 4.0입니다. 즉 쉘 버전 4.0에서는 DllGetVersion이라는 이름의 함수가 존재하지 않습니다.
여기서 bUseOwnerDraw는 그림을 출력할 수 있는 메뉴 항목을 사용할 수 있는지를 나타냅니다. 이 값이 참이 되면, 우리는 그림을 출력할 수 있는 메뉴 항목을 추가할 것이고(mii.fType을 설정하는 줄을 참고하기 바랍니다). 값이 거짓이면 비트맵 항목을 추가한 다음 컨텍스트 메뉴에게 사용자에게 보여줄 비트맵에 대한 핸들을 알려줍니다.
우리는 컨텍스트 메뉴에 새로운 항목을 추가하면서 m_uOurItemID 멤버변수에 그 ID를 추가했습니다. 이는 나중에 메시지가 전달될 때 ID를 식별하기 위하여 사용될 것입니다. 엄밀히 말하면 이 과정은 필요하지 않습니다, 왜냐하면 우리는 지금 하나의 메뉴 항목만을 만들었기 때문입니다. 그러나 실무적으로는 여러 개의 메뉴 항목을 만들 수도 있기 때문에 더 말할 것 없이 좋은 방식입니다.
class CBmpCtxMenuExt :
public CComObjectRootEx<CComSingleThreadModel>,
public CComCoClass<CBmpCtxMenuExt, &CLSID_BmpCtxMenuExt>,
public IShellExtInit,
public IContextMenu3 {
BEGIN_COM_MAP(CSimpleShlExt)
COM_INTERFACE_ENTRY(IShellExtInit)
COM_INTERFACE_ENTRY(IContextMenu) // 새로 추가
COM_INTERFACE_ENTRY(IContextMenu2) // 새로 추가
COM_INTERFACE_ENTRY(IContextMenu3) // 새로 추가
END_COM_MAP()
// 이 이하로 전부 새로 추가
public:
// IContextMenu
STDMETHODIMP QueryContextMenu(HMENU, UINT, UINT, UINT, UINT);
STDMETHODIMP InvokeCommand(LPCMINVOKECOMMANDINFO);
STDMETHODIMP GetCommandString(UINT_PTR, UINT, UINT *, LPSTR, UINT);
// IContextMenu2
STDMETHODIMP HandleMenuMsg(UINT, WPARAM, LPARAM);
// IContextMenu3
STDMETHODIMP HandleMenuMsg2(UINT, WPARAM, LPARAM, LRESULT *);
// ...
컨텍스트 메뉴 수정하기
이전과 같이 우리는 세 가지 IContextMenu 메소드에서 필요한 작업을 수행합니다. 그리고 우리는 QueryContextMenu 메소드에서 컨텍스트 메뉴에 항목들을 새로 추가합니다. 먼저 우리는 쉘의 버전을 체크합니다. 버전이 4.71 이상일 경우 우리는 컨텍스트 메뉴 항목에 그림을 출력합니다. 그렇지 않다면 우리는 비트맵을 보여주는 메뉴 항목을 추가합니다. 후자의 경우 우리가 해야 할 것은 항목을 추가하는 것이 전부입니다.
먼저 쉘 버전을 체크해야 합니다. 쉘의 버전을 얻기 위하여 쉘이 내보내는 함수인 DllGetVersion을 호출합니다. 그런 이름의 함수를 쉘이 내보내고 있지 않다면, 그 쉘의 버전은 4.0입니다. 즉 쉘 버전 4.0에서는 DllGetVersion이라는 이름의 함수가 존재하지 않습니다.
STDMETHODIMP CBmpCtxMenuExt::QueryContextMenu(HMENU hmenu, UINT uIndex, UINT uidCmdFirst, UINT uidCmdLast, UINT uFlags) {
// uFlags가 CMF_DEFAULTONLY를 포함하고 있으면 아무것도 작업해선 안 됩니다.
if (uFlags & CMF_DEFAULTONLY)
return MAKE_HRESULT(SEVERITY_SUCCESS, FACILITY_NULL, 0);
bool bUseOwnerDraw = false;
HINSTANCE hinstShell;
hinstShell = GetModuleHandle(_T("shell32"));
if (hinstShell != NULL) {
DLLGETVERSIONPROC pProc;
pProc = (DLLGETVERSIONPROC)GetProcAddress(hinstShell, "DllGetVersion");
if (pProc != NULL) {
DLLVERSIONINFO rInfo = { sizeof(DLLVERSIONINFO) };
if (SUCCEEDED(pProc(&rInfo))) {
if ((rInfo.dwMajorVersion > 4) || (rInfo.dwMajorVersion == 4 && rInfo.dwMinorVersion >= 71))
bUseOwnerDraw = true;
}
}
}
// ...
여기서 bUseOwnerDraw는 그림을 출력할 수 있는 메뉴 항목을 사용할 수 있는지를 나타냅니다. 이 값이 참이 되면, 우리는 그림을 출력할 수 있는 메뉴 항목을 추가할 것이고(mii.fType을 설정하는 줄을 참고하기 바랍니다). 값이 거짓이면 비트맵 항목을 추가한 다음 컨텍스트 메뉴에게 사용자에게 보여줄 비트맵에 대한 핸들을 알려줍니다.
// ...
MENUITEMINFO mii = {0};
mii.cbSize = sizeof(MENUITEMINFO);
mii.fMask = MIIM_ID | MIIM_TYPE;
mii.fType = bUseOwnerDraw ? MFT_OWNERDRAW : MFT_BITMAP;
mii.wID = uidCmdFirst;
if (!bUseOwnerDraw) {
// 주의: 이 구문은 컨텍스트 메뉴에 원래 크기의 비트맵 이미지를 넣습니다.
// 이러한 작동은 우리가 예상하던 그 작동이 아닙니다.
// 비트맵 이미지의 크기를 알맞게 축소하는 것은 여러분의 과제로 남겨놓겠습니다.
mii.dwTypeData = (LPTSTR)m_bmp.GetSafeHandle();
}
InsertMenuItem(hmenu, uIndex, TRUE, &mii);
// WM_MEASUREITEM이나 WM_DRAWITEM 이벤트가 전달될 경우 확인할 수 있도록 메뉴 항목의 ID를 보관해 둡니다.
m_uOurItemID = uidCmdFirst;
// 최상위 단계의 메뉴 항목을 1개 추가했으므로 이를 쉘에게 알립니다.
return MAKE_HRESULT(SEVERITY_SUCCESS, FACILITY_NULL, 1);
}
우리는 컨텍스트 메뉴에 새로운 항목을 추가하면서 m_uOurItemID 멤버변수에 그 ID를 추가했습니다. 이는 나중에 메시지가 전달될 때 ID를 식별하기 위하여 사용될 것입니다. 엄밀히 말하면 이 과정은 필요하지 않습니다, 왜냐하면 우리는 지금 하나의 메뉴 항목만을 만들었기 때문입니다. 그러나 실무적으로는 여러 개의 메뉴 항목을 만들 수도 있기 때문에 더 말할 것 없이 좋은 방식입니다.
상태 표시줄에 ‘플라이-바이 도움말(fly-by help)’을 보여주기
플라이 바이 도움말을 표시하는 것은 이전 단계에서 해 보았던 것과 다르지 않습니다. Windows 탐색기는 상태 표시줄에 출력할 문자열을 얻어가기 위해 GetCommandString을 호출할 것입니다.
#include <atlconv.h> // ATL 문자열 변환 매크로를 사용하기 위하여 필요한 헤더 파일
STDMETHODIMP CBmpCtxMenuExt::GetCommandString(UINT uCmd, UINT uFlags, UINT* puReserved, LPSTR pszName, UINT cchMax) {
USES_CONVERSION;
static LPCTSTR szHelpString = _T("Select this thumbnail to view the entire picture.");
// idCmd를 검사합니다.
// 우리는 하나의 메뉴 항목만을 추가했기 때문에 반드시 0 값만이 허용됩니다.
if (uCmd != 0)
return E_INVALIDARG;
// Windows 탐색기가 문자열을 요구할 때,
// 미리 준비된 문자열을 Windows 탐색기가 제공한 버퍼에 복사합니다.
if (uFlags & GCS_HELPTEXT) {
if (uFlags & GCS_UNICODE) {
// pszName을 유니코드 문자열로 변환해야 할 때,
// 문자열 복사 함수도 유니코드 버전을 씁니다.
lstrcpynW((LPWSTR)pszName, T2CW(szHelpString), cchMax);
} else {
// 유니코드를 요하지 않을 경우 ANSI 버전으로 복사합니다.
lstrcpynA(pszName, T2CA(szHelpString), cchMax);
}
}
return S_OK;
}
사용자 선택에 따라 작업 수행하기
IContextMenu와 관련해서 마지막으로 구현할 메소드는 InvokeCommand입니다. 이 메소드는 우리가 새로 추가한 컨텍스트 메뉴 항목을 사용자가 클릭했을 때 호출됩니다. 여기에서는 사용자가 클릭했을 때 .bmp 파일에 연결된 프로그램을 띄워서 해당 비트맵 이미지를 열게 하기 위하여 ShellExecute를 호출할 것입니다.
STDMETHODIMP CBmpCtxMenuExt::InvokeCommand(LPCMINVOKECOMMANDINFO pInfo) {
// lpVerb가 실제 존재하는 문자열을 가리키고 있을 경우
// 이 메소드의 호출을 무시하고 끝냅니다.
if (HIWORD(pInfo->lpVerb) != 0)
return E_INVALIDARG;
// 하나의 항목만 새로 추가했으므로 Command ID는 0밖에 없습니다.
if (LOWORD(pInfo->lpVerb) != 0)
return E_INVALIDARG;
// 기본 연결 프로그램을 띄워서 비트맵 파일을 엽니다.
int nRet;
nRet = (int)ShellExecute(pInfo->hwnd, _T("open"), m_szFile, NULL, NULL, SW_SHOWNORMAL);
return (nRet > 32) ? S_OK : E_FAIL;
}
여러분은 이제 6 단계까지 왔습니다. 필자는 상대적으로 덜 사용되는 쉘 익스텐션인 ‘드롭 핸들러(Drop Handler)’를 여러분에게 소개해 드리고자 합니다. 이 유형의 쉘 익스텐션은 ‘드롭’의 목표가 되는 파일에 따라 앞으로 호출될 쉘 익스텐션이 결정되고 Windows 탐색기의 ‘드래그 앤 드롭’ 기능을 프로그램적으로 새롭게 확장할 수 있습니다.
이번 단계에서는 여러분이 쉘 익스텐션의 기본에 대해 알고 있고, 쉘(shell)과 상호작용하기 위해 사용되는 MFC 클래스들에 친숙하다고 가정합니다. 여러분이 MFC 클래스에 대해 다시 알아보고자 한다면, 4 단계로 되돌아가시기 바랍니다. 4 단계는 이번 단계에서 사용하는 것과 같은 기법을 사용합니다.
4 단계에서 필자는 마우스 오른쪽 버튼을 누른 상태로 드래그 앤 드롭을 할 때 호출되는 드래그 앤 드롭 핸들러에 대해 설명하였습니다. Windows 탐색기는 또한 마우스 왼쪽 버튼을 누른 상태로 특정 파일에 ‘드롭’하는 드래그 앤 드롭 동작을 하는 동안에도 쉘 익스텐션이 호출될 수 있도록 만들어졌습니다. 예를 들어 WinZip은 다른 파일들을 드래그하여 .zip 파일 아이콘에 ’드롭’ 하였을 때 .zip 파일에 해당 파일들을 추가할 수 있는 쉘 익스텐션을 제공하고 있습니다. 여러분이 .zip 파일로 다른 파일들을 드래그하여 가져오면 Windows 탐색기는 .zip 파일이 ‘드롭’ 동작의 대상이 될 수 있음을 알리기 위하여 .zip 파일의 아이콘을 강조하고 마우스 포인터도 [+] 모양의 아이콘이 포함된 모양으로 바꿉니다.
드래그 동작이 zip 파일 위를 지나갈 때 마우스 포인터에 [+] 표시가 추가된다.
만일 드래그 중인 마우스 포인터가 ‘드롭’할 대상이 아닌 파일 위를 지나갈 때는 아무것도 변하지 않습니다.
드래그 동작이 exe 파일 위로 지나갈 때는 마우스 포인터에 아무 변화도 없다.
드롭 핸들러는 WinZip의 경우와 같이 여러분만의 파일 형식을 개발할 때 매우 유용하게 쓰일 수 있습니다. 드롭 핸들러를 더 흥미롭게 다루기 위해, “보내기(Send To)” 메뉴에 항목을 추가할 수도 있습니다. “보내기(Send To)” 메뉴는 SendTo라는 폴더의 항목을 보여줍니다. Windows 9x의 경우 이 폴더는 Windows 디렉토리 안에 있고, Windows NT 기반의 운영체제의 경우 사용자 프로필 디렉토리 안에 있습니다. 이전 버전의 Windows의 경우 SendTo 디렉토리는 단순히 바로가기 파일들만을 가지고 있었지만, Shell Power Toys 같은 서드파티 앱을 포함하여 새로운 버전의 Windows의 경우 다음과 같이 바로가기가 아닌 파일들도 보여줄 수 있습니다.
컨텍스트 메뉴의 ‘보내기(N)’ 메뉴(1).
위 그림에서 숨어있는 드롭 핸들러를 발견하지 못했다면 SendTo 폴더에 들어있는 파일 목록을 보시기 바랍니다.
.DeskLink와 같은 이상한 확장명이 있음에 주목하시기 바랍니다. 이들 0 바이트 파일은 “보내기(Send To)” 메뉴에 나타나기 위해 존재하는 파일들이고, 이들에 대한 쉘 익스텐션은 레지스트리에 등록되어 있습니다. 이들은 open이나 print와 같은 동사(verb)를 갖지 않기에 비록 일반적인 동작을 하는 것은 아니지만, 이들이 가지고 있는 것이 바로 드롭 핸들러입니다. 여러분이 이들 항목 중 하나를 “보내기(Send To)” 메뉴에서 선택하였을 때, Windows 탐색기는 해당 파일에 관련된 드롭 핸들러를 호출하게 됩니다.
‘드롭’ 동작 대상의 유형에 따라 메뉴 항목을 분류하면 다음과 같습니다.
컨텍스트 메뉴의 ‘보내기(N)’ 메뉴(2).
이번 단계의 예제 프로젝트는 예전에 사용되었던 “아무 폴더로 보내기(Send To Any Folder)”라는 도구의 클론이 되겠습니다. 이것은 선택한 파일을 여러분의 컴퓨터에서 접근 가능한 임의의 폴더로 이동 또는 복사되게 합니다.
초기화 인터페이스
이제 여러분은 지금까지 설명했던 프로젝트 생성 및 클래스 추가 등의 과정에 대해서는 익숙해 지셨을 것입니다. 그러므로 필자는 Visual C++ 마법사에 대한 설명은 생략하겠습니다. 새로운 ATL COM 프로젝트를 생성하고 그 이름을 SendToClone으로 지정합니다. 또한 CSendToShlExt라는 이름으로 C++ 클래스를 추가합니다.
드롭 핸들러는 드롭의 대상이 되는 하나의 파일에 대해서만 실행될 것이기 때문에, 쉘 익스텐션의 초기화는 IPersistFile 인터페이스를 통해 수행됩니다. IPersistFile 인터페이스는 한 번에 하나의 파일에 대해서만 호출되는 쉘 익스텐션이 사용함을 기억하시기 바랍니다.
IPersistFile은 여러 가지 메소드들을 가지고 있지만, 쉘 익스텐션에서는 Load 메소드만 구현하면 됩니다.
먼저 CSendToShlExt이 구현하는 인터페이스 목록에 IPersistFile을 추가해야 합니다. SendToShlExt.h를 열고 다음과 같이 추가합니다.
#include <comdef.h>
#include <shlobj.h>
class CSendToShlExt :
public CComObjectRootEx<CComSingleThreadModel>,
public CComCoClass<CSendToShlExt, &CLSID_SendToShlExt>,
public IPersistFile { // 새로 추가
BEGIN_COM_MAP(CSendToShlExt)
COM_INTERFACE_ENTRY(IPersistFile)
END_COM_MAP()
public:
// IPersistFile
STDMETHOD(GetClassID)(LPCLSID) { return E_NOTIMPL; } // 새로 추가
STDMETHOD(IsDirty)() { return E_NOTIMPL; } // 새로 추가
STDMETHOD(Load)(LPCOLESTR, DWORD) { return S_OK; } // 새로 추가
STDMETHOD(Save)(LPCOLESTR, BOOL) { return E_NOTIMPL; } // 새로 추가
STDMETHOD(SaveCompleted)(LPCOLESTR) { return E_NOTIMPL; } // 새로 추가
STDMETHOD(GetCurFile)(LPOLESTR*) { return E_NOTIMPL; } // 새로 추가
// ...
그런데 Load는 별 다른 작업 없이 그냥 S_OK를 반환하고 있습니다. Load 메소드는 ‘드롭’의 대상이 되는 파일의 전체 경로를 전달받기는 하지만, 지금 만들고 있는 쉘 익스텐션에서는 이를 사용하지 않을 것이므로 무시합니다.
드래그 앤 드롭 작동에 참여하기
이 작동을 구현하기 위하여 우리는 ‘드롭’이 발생하는 원천, 즉 Windows 탐색기와 통신할 필요가 있습니다. 우리의 쉘 익스텐션은 드래그되는 파일의 목록을 전달받고, Windows 탐색기에게 사용자의 ‘드롭’ 동작을 응할 것인지 여부를 말합니다. 이 통신 과정은 IDropTarget 인터페이스를 통해 수행됩니다. IDropTarget 인터페이스에는 다음과 같은 메소드가 들어 있습니다.
- DragEnter: 사용자의 드래그 동작이 특정 파일 위를 지나갈 때 호출됩니다. 이 메소드의 반환 값은 사용자가 해당 파일 위에 ‘드롭’할 때 Windows 탐색기에게 그 ‘드롭’ 동작에 응할 것인지 여부를 나타냅니다.
- DragOver: 쉘 익스텐션에서는 호출되지 않습니다.
- DragLeave: 사용자의 드래그 동작이 특정 파일 위에서 ‘드롭’하지 않고 그냥 벗어났을 때 호출됩니다.
- Drop: 사용자가 대상 파일에 ‘드롭’했을 때 호출됩니다. 쉘 익스텐션이 실질적으로 작동하는 부분입니다.
CSendToShlExt 클래스에 IDropTarget 인터페이스를 추가하기 위하여 SendToShlExt.h를 열고 다음과 같이 내용을 추가합니다.
class CSendToShlExt :
public CComObjectRootEx<CComSingleThreadModel>,
public CComCoClass<CSendToShlExt, &CLSID_SendToShlExt>,
public IPersistFile,
public IDropTarget { // 새로 추가
BEGIN_COM_MAP(CSendToShlExt)
COM_INTERFACE_ENTRY(IPersistFile)
COM_INTERFACE_ENTRY(IDropTarget) // 새로 추가
END_COM_MAP()
public:
// IDropTarget
STDMETHODIMP DragEnter(IDataObject* pDataObj, DWORD grfKeyState, POINTL pt, DWORD* pdwEffect); // 새로 추가
STDMETHODIMP DragOver(DWORD grfKeyState, POINTL pt, DWORD* pdwEffect) { return E_NOTIMPL; } // 새로 추가
STDMETHODIMP DragLeave(); // 새로 추가
STDMETHODIMP Drop(IDataObject* pDataObj, DWORD grfKeyState, POINTL pt, DWORD* pdwEffect); // 새로 추가
protected:
CStringList m_lsDroppedFiles; // 새로 추가
}
이전 단계에서 실습한 바와 같이, 우리는 드래그되고 있었던 파일들의 목록을 문자열 리스트로 보관하겠습니다. DragOver 메소드는 호출될 일이 없을 것이므로 특별히 구현할 것도 없습니다. 이제부터 나머지 세 가지 메소드에 대해 다루어 보겠습니다.
pDataObj는 IDataObject 인터페이스형 포인터로서 드래그되고 있는 파일들을 하나씩 열거할 수 있습니다.
grfKeyState는 플래그(flag)들의 집합으로서 Shift 키의 눌림 여부 및 어느 마우스 버튼이 클릭되었는지를 전달합니다.
pt는 POINT와 동일하게 작동하는 POINTL 구조체로서 마우스 포인터의 좌표를 나타냅니다.
pdwEffect는 DWORD에 대한 포인터로서, 우리가 ‘드롭’ 동작을 허용할 것인지, 허용한다면 마우스 포인터에 어떤 아이콘을 덧붙일 것인지 여부를 Windows 탐색기에게 반환할 장소입니다.
앞서 언급하였듯이, DragEnter는 일반적으로 사용자의 드래그 동작이 처음으로 대상이 될 수 있는 파일 위로 지나갈 때 호출된다고 하였습니다. 그러나 이 함수는 또한 사용자가 “보내기(Send To)” 메뉴의 항목을 클릭하였을 때도 호출됩니다. 따라서 우리는 기능적으로 드래그 앤 드롭이 발생하지 않은 때에도 DragEnter에서 작업을 수행할 수 있습니다.
DragEnter 구현은 먼저 드래그되고 있는 파일들의 목록으로 문자열 리스트를 채울 것입니다. 파일 시스템에 존재하는 어떤 개체이든 복사와 이동이 가능하기 때문에, 이 쉘 익스텐션은 모든 파일과 디렉토리를 받아들입니다. 이를 위해 DragEnter가 시작하는 내용은 여러분도 이미 친숙해지셨을 COleDataObject를 IDataObject에 연결시키고 하나씩 열거하는 작업입니다.
STDMETHODIMP CSendToShlExt::DragEnter(IDataObject * pDataObj, DWORD grfKeyState, POINTL pt, DWORD * pdwEffect) {
AFX_MANAGE_STATE(AfxGetStaticModuleState()); // MFC 초기화
COleDataObject dataobj;
TCHAR szItem[MAX_PATH];
UINT uNumFiles;
HGLOBAL hglobal;
HDROP hdrop;
dataobj.Attach(pDataObj, FALSE);
// 데이터 오브젝트로부터 리스트의 항목들을 하나씩 읽어옵니다.
// 이들 항목은 HDROP 형태로 보관되어있기 때문에 HDROP 핸들을 가져와서 드래그 앤 드롭 API를 적용합니다.
hglobal = dataobj.GetGlobalData(CF_HDROP);
if (hdrop != NULL) {
hdrop = (HDROP)GlobalLock(hglobal);
uNumFiles = DragQueryFile(hdrop, 0xFFFFFFFF, NULL, 0);
for (UINT uFile = 0; uFile < uNumFiles; uFile++) {
if (DragQueryFile(hdrop, uFile, szItem, MAX_PATH) != 0)
m_lsDroppedFiles.AddTail(szItem);
}
GlobalUnlock(hglobal);
}
// ...
이제 pdwEffect를 통해 값을 반환할 차례입니다. 우리가 반환할 수 있는 효과에는 다음과 같은 것들이 있습니다.
- DROPEFFECT_COPY: Windows 탐색기에게 드래그된 파일이 본 쉘 익스텐션에 의해 복사될 것임을 알려줍니다.
- DROPEFFECT_MOVE: Windows 탐색기에게 드래그된 파일이 본 쉘 익스텐션에 의해 이동될 것임을 알려줍니다.
- DROPEFFECT_LINK: Windows 탐색기에게 드래그된 파일이 본 쉘 익스텐션에 의해 링크될 것임을 알려줍니다.
- DROPEFFECT_NONE: Windows 탐색기에게 본 쉘 익스텐션은 드래그된 파일들을 받지 않을 것임을 알려줍니다.
우리가 반환하게 될 단 하나의 효과는 DROPEFFECT_COPY입니다.
본 쉘 익스텐션에서는 DROPEFFECT_MOVE를 반환하지 않겠습니다. 왜냐하면 이 효과는 ‘드롭’ 동작이 발생할 때 Windows 탐색기가 원래 있던 파일들을 삭제할 수 있기 때문입니다.
또한 DROPEFFECT_LINE를 반환할 수 있습니다. 그러나 마우스 포인터 모양이 바로가기 아이콘을 만들 때처럼 작은 화살표가 덧붙어서 나타나게 됩니다. 이 경우 사용자에게 오해를 불러일으킬 소지가 있습니다.
클립보드를 읽을 수 없어서 파일 리스트가 비어있다면, Windows 탐색기에게 ‘드롭’ 작업을 받지 않음을 알려주기 위하여 DROPEFFECT_NONE을 반환합니다.
우리가 구현할 Drop 메소드는 메인 다이얼로그 창을 생성하고 파일 이름들의 목록을 여기로 전달할 것입니다. 다이얼로그 안에서 관계된 모든 작업을 수행할 것이고, DoModal 메소드가 값을 반환할 때 우리는 ‘드롭’ 작업에 대한 최종 효과를 Windows 탐색기에게 반환할 것입니다.
딱히 복잡하지 않은 MFC 다이얼로그입니다. 이에 대한 소스 코드는 SendToCloneDlg.cpp에서 보실 수 있습니다. 필자는 이미 “CShellFileOp - SHFileOperation의 래퍼 클래스”라는 게시글을 통해 CShellFileOp 클래스를 소개한 바 있고, 이를 사용하여 실제로 파일을 옮기거나 복사하였습니다.
2 단계에서 다루었던 쉘 익스텐션과 마찬가지로 우리는 Windows XP에서 실행되는 경우 테마 적용을 하기 위해 매니페스트(manifest)를 DLL에 리소스로서 추가할 필요가 있습니다. 그러나 이런 경우 매니페스트만을 추가하는 것만으로는 충분하지 않습니다. 왜냐하면 다이얼로그를 생성하고 관리하는 소스 코드는 MFC 내부에 있기 때문입니다.
MFC 자체가 ISOLATION_AWARE_ENABLED 심볼을 적용하여 컴파일되지 않은 관계로, 테마를 사용하기 위해 꼭 필요한 IsolationAwareXxx 래퍼를 사용할 수 없습니다.
이 상황을 잘 설명한 글로 Dave Anderson 님의 뉴스그룹 스레드가 있습니다. 이 글을 요약하자면, 우리가 다이얼로그를 보여주기 전에, Windows가 우리가 추가한 매니페스트를 사용하고 우리의 다이얼로그에 버전 6.0 공용 컨트롤을 적용할 수 있도록 우리는 activation context API를 사용해야 합니다. 이에 대한 코드는 CActCtx 클래스에 캡슐화되어 있고 Drop 메소드 안에서 사용됩니다.
이와 같이 코드를 수정하면 다이얼로그는 Windows XP 테마가 적용되어 표시될 것입니다.
잠깐! Windows 탐색기에게 우리가 만든 드롭 핸들러를 어떻게 알려줄 수 있을까요? 어떻게 하면 “보내기(Send To)” 메뉴에 새로운 항목을 추가시킬 수 있을까요? 다음 절에서 설명하겠습니다.
쉘 익스텐션을 등록하기
드롭 핸들러를 등록하는 것은 다른 쉘 익스텐션을 등록하는 것과는 다소 다릅니다. HKEY_CLASSES_ROOT의 하위 키로 ‘연결 프로그램’에 대한 내용을 생성해야 하기 때문입니다. AppWizard가 생성한 RGS 스크립트가 다음과 같이 있을 텐데, 표시된 부분은 여러분이 직접 추가하거나 수정할 부분을 뜻합니다.
HKCR {
.SendToClone = s 'CLSID\{B7F3240E-0E29-11D4-8D3B-80CD3621FB09}' // 새로 추가
NoRemove CLSID {
ForceRemove {B7F3240E-0E29-11D4-8D3B-80CD3621FB09} = s 'Send To Any Folder Clone' { // 기본값 수정 가능
InprocServer32 = s '%MODULE%' {
val ThreadingModel = s 'Apartment'
}
val NeverShowExt = s '' // 새로 추가
DefaultIcon = s '%MODULE%,0' // 새로 추가
shellex { // 새로 추가
DropHandler = s '{B7F3240E-0E29-11D4-8D3B-80CD3621FB09}' // 새로 추가
}
}
}
}
첫 번째 줄이 ‘연결 프로그램’에 대한 것입니다.
이 줄은 새로운 확장명을 정의합니다. .SendToClone이라는 확장명은 우리가 만들 드롭 핸들러가 적용될 대상 아이콘을 만들기 위해서만 사용될 것입니다. .SendToClone 레지스트리 키의 기본 값이 CLSID\라는 접두어가 붙은 것을 주의하셔야 합니다. 이것은 Windows 탐색기에게 해당 연결 프로그램에 대한 내용이 HKCR\CLSID에 있음을 알려줍니다. 원래 이런 내용들은 보편적으로 쓰이는 확장명처럼 HKEY_CLASSES_ROOT 위치에 저장됩니다. 예를 들어 확장명 .txt에 대한 레지스트리 키는 다시 HKEY\txtfile를 가리킵니다. 그러나 모든 데이터를 한 곳에 몰아서 보관하기 위해서 드롭 핸들러에 대한 연결 프로그램 정보는 관습적으로 CLSID 레지스트리 키 위치에 보관되는 것으로 보입니다.
“Send To Any Folder Clone” 문자열은, 여러분이 SendTo 폴더를 열고 해당 확장명에 대한 0 바이트 파일을 선택했을 때, Windows 탐색기에서 파일 유형을 설명하기 위해 보여줄 문자열입니다.
NeverShowExt 값은 Windows 탐색기에게 .SendToClone의 확장명을 갖는 파일에 대해 그 확장명을 보여주어서는 안 된다고 알려주는 값입니다.
DefaultIcon 키는 .SendToClone 확장명을 갖는 파일에 대해 사용할 아이콘들의 위치를 담고 있습니다.
마지막으로 DropHandler라는 서브 키를 갖는 ShellEx 레지스트리 키가 있습니다. 해당 확장명을 갖는 파일에 대해서는 하나의 드롭 핸들러만이 존재할 것이기 때문에 DropHandler의 하위 키를 두고 그 위치에 GUID를 넣는 대신, DropHandler에 직접 GUID를 넣겠습니다.
이제 남은 것은 SendTo 폴더에 우리의 메뉴 항목이 나타나도록 파일을 생성하는 것입니다. 이 과정은 DllRegisterServer에서 수행할 수 있고, DllUnregisterServer를 통해 파일을 삭제할 수도 있습니다. 파일 생성의 예는 다음과 같습니다.
이제 프로퍼티 페이지 그 자체에 대해 살펴봅시다. 새로 추가될 페이지는 다음과 같이 생겼습니다. 이 모양을 잘 기억해 두었다가, 페이지가 어떻게 작동되는지를 설명할 때 참고하시기 바랍니다.
우리가 만들 쉘 익스텐션이 생성하는 탭
마지막으로 액세스된 시각 항목이 없을 수도 있습니다. FAT 파일 시스템만이 마지막으로 액세스된 날짜 항목을 기록하고 있습니다(번역자 주: FAT 파일 시스템은 시각까지는 기록하지 않는다는 의미입니다). 다른 파일 시스템은 시각까지 기록을 합니다만, 필자는 파일 시스템에 따라 이를 다르게 처리하는 기능까지는 구현하지 않겠습니다. 마지막으로 액세스된 시각까지 기록할 수 있는 파일 시스템에서 시각은 항상 사용자가 지정한 날짜의 자정으로 기록될 것입니다.
추가되는 페이지는 두 개의 콜백 함수와 두 개의 메시지 핸들러를 가지고 있습니다. 이들의 원형은 FileTimeShlExt.cpp의 상단에 적혀있습니다.
마지막으로 DTN_DATETIMECHANGE가 있습니다. 이것은 간단합니다. 우리가 만들고 있는 페이지의 부모 윈도우인 프로퍼티 시트에 메시지를 보내서 [적용] 버튼을 활성화시키는 것입니다.
// ...
case DTN_DATETIMECHANGE:
// 사용자가 DTP 컨트롤의 값을 변경하였다면,
// [적용] 버튼을 활성화시키도록 부모 윈도우에게 메시지를 보냅니다.
SendMessage(GetParent(hwnd), PSM_CHANGED, (WPARAM)hwnd, 0);
break;
} // switch 끝
} // case WM_NOTIFY의 끝
break;
} // switch의 끝
return bRet;
}
지금까지는 순조로웠습니다. 페이지가 생성되거나 파괴될 때 또 다른 콜백 함수가 호출됩니다. AddPages에서 생성된 문자열 복사본을 해제해야 하므로 우리는 후자에 대해 다루면 됩니다. ppsp 매개 변수(parameter)는 페이지를 생성하는 데 사용했던 PROPSHEETPAGE 구조체를 가리키고 있습니다. 그리고 lParam 멤버는 해제되어야 할 문자열 복사본을 여전히 가리키고 있습니다.
이 함수는 페이지가 생성될 때 호출될 때 호출되기 때문에 항상 1을 반환합니다. 0을 반환하면 페이지가 생성되지 않을 수 있습니다. 1을 반환하는 것은 페이지에게 자기자신이 정상적으로 생성되고 있음을 알려줍니다. 페이지가 파괴될 때 호출될 때는 이 함수의 반환 값이 무엇이든 되었든 무시됩니다.
프로퍼티 페이지 메시지 핸들러
OnInitDialog에서 중요한 작업들이 많이 발생했습니다. lParam 매개 변수(parameter)는 본 페이지를 생성하는데 사용된 PROPSHEETPAGE 구조체를 가리킵니다. 이것의 lParam 멤버는 앞서 말한 그 문자열 복사본을 가리킵니다. 우리는 OnApply 메서드에서 파일 이름이 필요할 것이라고 했는데, 그 파일 이름을 SetWindowLong을 호출하여 윈도우에 저장합니다.
BOOL OnInitDialog(HWND hwnd, LPARAM lParam) {
PROPSHEETPAGE * ppsp = (PROPSHEETPAGE*) lParam;
LPCTSTR szFile = (LPCTSTR) ppsp->lParam;
HANDLE hFind;
WIN32_FIND_DATA rFind;
// 나중에 사용하기 위하여, 파일 이름에 대한 포인터를 윈도우 사용자 데이터에 보관합니다.
SetWindowLong(hwnd, GWL_USERDATA, (LONG)szFile);
// ...
다음으로 우리는 FindFirstFile을 사용하여 특정 파일에 대한 생성된 날짜 및 시각, 수정된 날짜 및 시각 그리고 마지막으로 액세스된 날짜 및 시각을 가져옵니다. 가져오는 데 성공하였다면 DTP 컨트롤은 이들 날짜 및 시각으로 초기화될 것입니다.
OnApply 핸들러는 이와 반대의 작동을 합니다. 즉, DTP 컨트롤의 값을 읽고 파일에 대해 생성된 날짜 및 시각, 수정된 날짜 및 시각 그리고 마지막으로 수정된 날짜 및 시각을 고칩니다. 이에 대한 첫 번째 단계로 GetWindowLong을 사용하여 고칠 파일에 대한 전체 경로가 담긴 문자열 포인터를 얻습니다.
파일을 열 수 있다면, DTP 컨트롤로부터 날짜와 시각들을 읽어서 파일을 고칩니다. ReadDTPCtrl은 SetDTPCtrl과 반대의 작동(DTP 컨트롤의 현재 값으로 파일을 수정)을 합니다.
if (hFile != INVALID_HANDLE_VALUE) {
// DTP 컨트롤에서 날짜와 시각을 가져옵니다.
ReadDTPCtrl(hwnd, IDC_MODIFIED_DATE, IDC_MODIFIED_TIME, &ftModified);
ReadDTPCtrl(hwnd, IDC_ACCESSED_DATE, 0, &ftAccessed);
ReadDTPCtrl(hwnd, IDC_CREATED_DATE, IDC_CREATED_TIME, &ftCreated);
// 파일의 생성된 날짜 및 시각, 수정된 날짜 및 시각, 마지막으로 액세스된 날짜 및 시각을 고칩니다.
SetFileTime(hFile, &ftCreated, &ftAccessed, &ftModified);
CloseHandle ( hFile );
} else {
// 오류 처리 부분은 생략합니다.
}
// 사용자가 [확인] 버튼을 클릭하였을 때 창이 닫힐 수 있도록 PSNRET_ERROR를 반환합니다.
SetWindowLong(hwnd, DWL_MSGRESULT, PSNRET_NOERROR);
return TRUE;
}
쉘 익스텐션을 등록하기
프로퍼티 시트 익스텐션을 등록하는 것은 컨텍스트 메뉴 익스텐션 또는 드래그 앤 드롭 익스텐션을 등록하는 것과 비슷합니다. 앞서 실습한 쉘 익스텐션들은 텍스트 파일과 같은 특정 파일에 대해서만 호출되도록 등록할 수 있었습니다만, 이 프로퍼티 시트 익스텐션은 임의의 파일에 대해서 작동되도록 만들었기 때문에 HKEY_CLASSES_ROOT\* 키의 하위키로 등록해 보겠습니다. 본 쉘 익스텐션을 등록하기 위한 RGS 스크립트는 다음과 같이 생겼습니다.
여러분은 본 쉘 익스텐션의 GUID가 문자열 형태의 기본 값이 아니라, 레지스트리 키의 이름으로서 등록되는 것을 확인하실 수 있습니다. 필자가 확인한 개발문서 및 관련 서적들에서는 다소 논쟁이 존재하기는 하지만, 필자가 테스트를 해 보았을 때 둘 다 작동을 하였습니다. 때문에 필자는 Dino Esposito님의 서적인 “Visual C++ Windows Shell Programming”에서 설명하고 있는 방식에 따르겠습니다. 레지스트리 키의 이름으로 본 쉘 익스텐션의 GUID를 지정합니다.
Windows NT 기반의 운영체제에서는, “승인된(approved)” 익스텐션 목록에 본 쉘 익스텐션을 추가합니다. 이 작업은 예제 프로젝트의 DllRegisterServer 및 DllUnregisterServer 함수에 적혀있습니다.
다음 단계에서 다룰 내용
6 단계에서 우리는 또 다른 형태의 쉘 익스텐션인 드롭 핸들러에 대해 다루어 보겠습니다. 이것은 쉘 개체가 파일 속으로 ‘드롭’될 때 호출되는 쉘 익스텐션입니다.
프로퍼티 시트의 세계로 모험해 보겠습니다. 여러분이 파일 시스템 개체에 대해 등록정보(또는 속성) 다이얼로그를 띄웠을 때 Windows 탐색기는 “일반”이라는 이름이 붙은 탭과 함께 프로퍼티 시트를 보여줄 것입니다. 이 때 프로퍼티 시트 핸들러(property sheet handler)라고 하는 쉘 익스텐션을 사용하면 프로퍼티 시트에 페이지를 추가할 수 있습니다.
이 단계에서 필자는 여러분이 쉘 익스텐션의 기본을 이해하고 있고, STL 콜렉션 클래스에 대해 익숙할 것이라 가정하겠습니다. STL에 대해 다시 익히고자 하는 분은 이 단계에서 쓰이는 것과 같은 기법이 쓰이는 3 단계을 참고하시기 바랍니다.
Visual C++ 7.0 또는 8.0 사용자는 이전과 같이 1 단계의 본 시리즈에 들어가며......를 참고하여 컴파일하기 전 몇 가지 설정을 변경해야 함을 기억하시기 바랍니다.
Windows 사용자라면 Windows 탐색기의 등록정보(또는 속성) 다이얼로그가 친숙하실 것입니다. 특히 프로퍼티 시트(property sheet)는 하나 이상의 페이지를 포함할 수 있습니다. 각 프로퍼티 시트는 “일반” 이라는 탭 속에 전체 경로, 수정된 날짜 기타 사항들을 나열하고 있습니다. Windows 탐색기는 또한 우리가 프로퍼티 시트 핸들러 익스텐션(property sheet handler extension)을 사용하여 페이지를 직접 추가할 수 있게 해주고 있습니다.
프로퍼티 시트 핸들러는 또한 특정 제어판 항목에서 페이지를 추가 또는 교체할 수 있는데, 이번 단계에서는 그러한 내용을 다루지 않겠습니다. 제어판 항목을 확장하는 것에 관심있는 분은 필자의 또 다른 글인 “제어판 항목에 사용자 페이지 추가하기”를 읽어보시기 바랍니다.
이번 단계에서는 특정 파일에 대한 등록정보(또는 속성) 다이얼로그에서 사용자가 직접 생성된 날짜, 수정된 날짜, 마지막으로 액세스한 날짜 등을 직접 수정할 수 있는 쉘 익스텐션을 설명합니다. 또한 필자는 이번에는 MFC나 ATL 사용 없이 SDK를 직접 호출함으로써 프로퍼티 페이지를 다루겠습니다. 필자는 쉘 익스텐션에서 MFC 또는 WTL 프로퍼티 페이지를 사용하지 않았기 때문에, 이와 같은 방식이 까다로워 보일 수 있습니다.
왜냐하면 Windows 탐색기는 프로퍼티 시트가 HPROPSHEETPAGE라는 핸들을 받을 수 있을 것으로 알고 있고, MFC는 CPropertyPage 구현에서 그러한 작업을 사용자에게 감추어왔기 때문입니다.
여러분이 인터넷 바로가기 파일인 .url 파일에 대해 등록정보(또는 속성) 다이얼로그를 띄웠을 때, 여러분은 프로퍼티 시트 핸들러가 작동함을 보실 수 있습니다. “라디오 방송국 안내”라는 이름의 탭이 이번 단계의 쉘 익스텐션으로 인해 등장했습니다. 또한 “웹 문서”라는 이름의 탭은 Internet Explorer가 설치되어 있음으로 인해 나타나는 탭입니다.
Internet Explorer가 생성한 ‘웹 문서’ 탭.
초기화 인터페이스
이제 여러분은 지금까지 설명했던 프로젝트 생성 및 클래스 추가 등의 과정에 대해서는 익숙해 지셨을 것입니다. 그러므로 필자는 Visual C++ 마법사에 대한 설명은 생략하겠습니다. 새로운 ATL COM 프로젝트를 생성하고 그 이름을 FileTime으로 지정합니다. 또한 CFileTimeShlExt라는 이름으로 C++ 클래스를 추가합니다.
프로퍼티 시트 핸들러는 선택된 다수의 파일들에 대해 한번에 작동될 수 있으므로 초기화 인터페이스로 IShellExtInit를 사용합니다. 따라서 우리는 CFileTimeShlExt 클래스가 구현하는 인터페이스 목록에 IShellExtInit를 추가합니다. 다시 반복하지만 지금부터 필자는 이러한 단계에 여러분께서 이제 익숙해지셨을 것이라 생각하여 세세한 과정을 반복 설명하지는 않겠습니다.
우리가 새로 만들 페이지에는 날짜/시간 선택자(DTP: date/time picker) 컨트롤이 포함되어 있기 때문에 공용 컨트롤을 초기화해 주었습니다. 그 다음 우리는 IDataObject 인터페이스 구현과 씨름해야 하고, 선택된 각 파일들을 하나씩 순회할 수 있는 HDROP 핸들도 얻습니다.
// ...
// 데이터 오브젝트로부터 목록을 읽은 후 HDROP 형식으로 보관합니다.
// 그 다음 HDROP에 대해 드래그 앤 드롭 API를 사용할 것입니다.
if (FAILED( pDataObj->GetData(&etc, &stg)))
return E_INVALIDARG;
// HDROP 핸들을 얻습니다.
hdrop = (HDROP)GlobalLock(stg.hGlobal);
if (hdrop == NULL) {
ReleaseStgMedium(&stg);
return E_INVALIDARG;
}
// 몇 개의 파일들이 선택되었는지 확인합니다.
uNumFiles = DragQueryFile(hdrop, 0xFFFFFFFF, NULL, 0);
// ...
다음으로 선택된 파일들을 실제로 하나씩 순회하는 루프가 등장합니다. 이번 쉘 익스텐션은 디렉토리가 아닌 파일에만 작동될 것이므로 루프 도중 만나게 되는 모든 디렉토리는 무시될 것입니다.
// ...
for (UINT uFile = 0; uFile < uNumFiles; uFile++) {
// 파일 이름을 하나씩 읽습니다.
if (DragQueryFile(hdrop, uFile, szFile, MAX_PATH))
continue;
// 디렉토리 여부를 확인하여 건너뜁니다.
// 물론 디렉토리도 날짜와 시각을 수정할 수는 있지만,
// 필자는 이번 예제에서 이에 대해 다루지 않겠습니다.
if (PathIsDirectory(szFile))
continue;
// 작업을 수행할 파일 리스트에 현재의 파일 이름을 추가합니다.
m_lsFiles.push_back ( szFile );
} // for 루프의 끝
// 리소스를 해제합니다.
GlobalUnlock(stg.hGlobal);
ReleaseStgMedium(&stg);
// ...
문자열 리스트에서 파일 이름들을 하나씩 순회하는 것은 이전 단계에서 했던 것과 동일합니다. 그러나 한 가지 새로운 것이 등장합니다. 프로퍼티 시트는 자신이 가질 수 있는 페이지의 최대 수가 제한되어 있습니다. 이는 prsht.h의 MAXPROPPAGES라는 상수로 정의되어 있습니다. 각 파일들은 자신만의 페이지를 가질 수 있지만, 파일 이름들을 포함하는 문자열 리스트가 MAXPROPPAGES 이상인 경우 넘어가는 부분은 잘라내어 MAXPROPPAGES에서 지정한 개수에 맞출 것입니다. 물론 현재는 MAXPROPPAGES가 100으로 되어 있지만, 프로퍼티 시트가 그렇게까지 많은 페이지를 보여주지는 않습니다. 실질적으로 34개 전후가 최대입니다.
// ...
// 몇 개의 파일들이 선택되었는지 검사합니다.
// 프로퍼티 페이지의 최대 개수보다 더 크면 문자열 리스트를 잘라냅니다.
if (m_lsFiles.size() > MAXPROPPAGES)
m_lsFiles.resize(MAXPROPPAGES);
// 우리가 작업할 수 있는 파일이 하나라도 있다면 S_OK를 반환합니다.
// 그렇지 않다면 E_FAIL을 반환하여 이번 마우스 오른쪽 클릭에 대해서는,
// 다시 호출되는 일이 없게 합니다.
return (m_lsFiles.size() > 0) ? S_OK : E_FAIL;
}
프로퍼티 페이지 추가하기
Initialize 메소드가 S_OK를 반환하면 Windows 탐색기는 새롭게 등장하는 인터페이스인 IShellPropSheetExt에 대한 포인터를 요구합니다.
IShellPropSheet는 다소 단순한 편이어서 구현이 필요한 메소드는 딱 하나면 됩니다. FileTiimeShlExt.h를 열고 IShellPropSheetExt 인터페이스를 추가하기 위해 다음과 같이 내용을 추가합니다.
class CFileTimeShlExt :
public CComObjectRootEx<CComSingleThreadModel>,
public CComCoClass<CFileTimeShlExt, &CLSID_FileTimeShlExt>,
public IShellExtInit,
public IShellPropSheetExt { // 새로 추가
BEGIN_COM_MAP(CFileTimeShlExt)
COM_INTERFACE_ENTRY(IShellExtInit)
COM_INTERFACE_ENTRY(IShellPropSheetExt)
END_COM_MAP()
public:
// IShellPropSheetExt
STDMETHODIMP AddPages(LPFNADDPROPSHEETPAGE, LPARAM); // 새로 추가
STDMETHODIMP ReplacePage(UINT, LPFNADDPROPSHEETPAGE, LPARAM) { return E_NOTIMPL; } // 새로 추가
AddPage 메소드가 우리가 구현할 메소드입니다. ReplacePage 메소드는 제어판 항목에서 페이지들을 교체하는 쉘 익스텐션에서만 사용되므로 우리는 이것을 여기서 구현할 필요는 없습니다. Windows 탐색기는 구성할 등록 정보(또는 속성) 창에 페이지를 추가할 수 있도록 AddPages 메소드를 호출할 것입니다.
AddPages가 호출될 때 전달되는 매개 변수(parameter)는 함수 포인터와 LPARAM이고 둘 다 쉘이 사용합니다. lpfnAddPageProc은 우리가 실질적으로 페이지를 추가하기 위해 호출하게 될 쉘 내부의 함수를 가리키는 포인터입니다. lParam은 쉘에게 매우 중요하지만 수수께끼 같은 값입니다. 따라서 우리는 이 값을 변경하지 않고 전달받은 그대로 lpfnAddPageProc이 가리키는 함수에 전달만 하겠습니다.
STDMETHODIMP CFileTimeShlExt::AddPages(LPFNADDPROPSHEETPAGE lpfnAddPageProc, LPARAM lParam) {
PROPSHEETPAGE psp;
HPROPSHEETPAGE hPage;
TCHAR szPageTitle[MAX_PATH];
string_list::const_iterator it, itEnd;
for (it = m_lsFiles.begin(), itEnd = m_lsFiles.end(); it != itEnd; it++) {
// 반복자 it는 리스트에서 파일 이름을 하나씩 가져옵니다.
// 프로퍼티 페이지가 소유할 수 있도록 해당 문자열의 복사본을 새로 할당합니다.
LPCTSTR szFile = _tcsdup(it->c_str());
// ...
먼저 파일 이름에 대한 복사본을 생성합니다. 그 이유는 조금 있다가 설명하겠습니다.
다음으로 우리가 추가할 페이지의 탭에 대한 제목 문자열을 구성합니다. 이 문자열은 파일 이름을 그대로 가져와서 써도 됩니다. 참고로 24자가 넘으면 자동으로 문자열이 잘릴 수 있는데 이 문자수의 기준은 임의적이어서 필자가 테스트해 본 바로는 24자까지는 괜찮게 보였습니다. 그렇지만 글자가 탭의 경계를 벗어나서 보여지지 않도록 글자수를 제한할 필요는 있습니다.
// ...
// 파일 이름으로부터 상위 경로와 확장명을 제거합니다.
// 이 문자열은 페이지의 제목이 될 것이고,
// 길이 또한 탭의 너비에 맞추어 24자로 제한합니다.
lstrcpyn(szPageTitle, it->c_str(), MAX_PATH);
PathStripPath(szPageTitle);
PathRemoveExtension(szPageTitle);
szPageTitle[24] = '\0';
// ...
프로퍼티 페이지를 만들고 추가하기 위하여 SDK를 직접 호출할 것이기 때문에 우리는 PROPSHEETPAGE 구조체를 가지고 손에 기름때를 묻혀가며 다소 지저분한 작업을 해야 합니다. 구조체를 구성하는 예는 다음과 같습니다.
1. pszIcon은 탭 제목과 함께 보여질 16 * 16 크기의 아이콘에 대한 리소스 ID입니다. 물론 아이콘을 지정하는 것은 선택적인 사항이지만, 우리가 추가할 페이지가 돋보이도록 필자는 아이콘을 추가하겠습니다.
2. pfnDlgProc은 우리가 추가할 페이지의 다이얼로그 프로시저에 대한 주소입니다.
3. lParam은 지금 추가하고 있는 페이지와 관계된 파일의 이름이자 직전에 생성했던 복사본인 szFile을 직접 지정합니다.
4. pfnCallback은 페이지가 생성되거나 파괴될 때 호출되는 콜백 함수의 주소입니다. 이 함수의 역할은 잠시 후 설명하겠습니다.
pcRefParent는 CComModule에서 상속된 어떤 클래스의 멤버 변수에 대한 주소입니다. 사실 이 값은 DLL이 잠긴 횟수입니다. 프로퍼티 시트가 보여질 때마다 쉘은 이 값을 하나씩 증가시킴으로써 프로퍼티 시트가 보여지는 동안 DLL이 메모리에서 해제되는 것을 방지해 줍니다. 프로퍼티 시트가 파괴되면 이 값도 하나씩 감소합니다.
위 함수의 호출이 성공하면 우리는 쉘에서 제공한 콜백 함수를 호출하여 프로퍼티 시트에 새로운 페이지를 추가합니다. 이 콜백 함수는 성패 여부에 따라 BOOL을 반환할 것인데, 만일 실패하면 지금까지 만들었던 프로퍼티 시트 페이지를 파괴해야 합니다.
// ...
if (hPage != NULL) {
// 프로퍼티 시트에 페이지를 추가하도록, 쉘에서 제공한 콜백 함수를 호출합니다.
if (!lpfnAddPageProc(hPage, lParam))
DestroyPropertySheetPage(hPage);
}
} // for 루프 종료
return S_OK;
}
객체의 수명주기를 고려하여 세심하게 고려해 주어야 할 상황
이제 파일 이름을 나타내는 문자열에 대해 왜 복사본을 만들었는지 설명할 차례가 되었습니다. AddPages가 값을 반환할 때, 쉘은 IShellPropSheetExt 인터페이스에 대한 참조 횟수를 감소시킵니다. 따라서 CFileTimeShlExt 클래스형 객체가 파괴될 수 있는데 이는 프로퍼티 페이지를 포함하고 있는 다이얼로그 및 이것을 다루는 다이얼로그 프로시저가 CFileTimeShlExt의 멤버인 m_lsFiles에 접근할 수 없음을 뜻합니다.
필자의 해법은 각 파일 이름에 대한 복사본을 생성하여 페이지마다 그 포인터를 전달하는 것이었습니다. 대신 각 페이지는 자신만의 메모리를 소유하고 있기 때문에, 다이얼로그가 파괴될 때 이 복사본을 직접 할당 해제해 주어야 합니다.
선택된 파일이 하나 이상일 때, 각 페이지는 자신과 관계된 파일 이름에 대한 복사본을 얻습니다. 그리고 이 복사본은 이어서 설명할 PropPageCallbackProc 함수에서 해제됩니다.
그렇기 때문에 AddPages 메소드에서 다음과 같은 부분은 중요합니다.
psp.lParam = (LPARAM) szFile;
이 문장은 복제된 문자열에 대한 포인터를 PROPSHEETPAGE 구조체에 보관하고, 페이지에 대한 다이얼로그 프로시저가 이를 사용할 수 있게 합니다.