티스토리 뷰

2장에서는 안드로이드의 동작을 처리하는 이벤트 처리와, 기본 위젯이지만 사용법이 조금 복잡한 리스트 뷰에 대해 설명한다. 이번 포스팅에서는 여러 이벤트와 이벤트 처리 방법, 리스트 뷰에 대해 정리하려고 한다. 또한 추가적으로 java 파일에서 위젯을 추가할 때 필요한 '인플레이션'의 개념과, 나인패치 이미지와 비트맵 버튼에 대해서도 나온다.

 

[이벤트]

이벤트 처리 방식

버튼을 눌렀을 때 어떤 이벤트를 하게 동작하고 싶을 때, 다음과 같은 패턴을 따른다.

버튼에 OnClickListener를 설정할 때의 패턴

이 패턴은 화면에서 발생하는 이벤트를 버튼 객체에 전달한 후 그 이후의 처리 과정을 버튼에 위임한다고 해서 '위임 모델(Delegation Model)'이라고 부른다. 이 패턴을 사용하면 각각의 뷰마다 하나의 이벤트 처리 루틴을 할당해 주기 때문에 코드가 간단해지고 객체 지향 코드를 만들 수 있는 장점이 있다. 

이렇게 버튼에 리스너를 등록해두면 버튼이 클릭 될 때마다 리스너의 메서드가 자동으로 호출된다.

 

 이벤트 종류

속성 설명
터치 이벤트 화면을 손가락으로 누를 때 발생하는 이벤트
키 이벤트 키패드나 하드웨어 버튼을 누를 때 발생하는 이벤트
제스처 이벤트 터치 이벤트 중에서 스크롤과 같이 일정 패턴으로 구분되는 이벤트
포커스 뷰마다 순서대로 주어지는 포커스
화면 방향 변경 화면의 방향이 가로와 세로로 바뀜에 따라 발생하는 이벤트

 

터치 이벤트는 사용자가 손가락으로 화면을 터치할 때마다 발생하는 이벤트인데 이 중에서 일정한 패턴, 즉 손가락으로 좌우로 스크롤할 때와 같은 패턴을 '제스처(Gesture)'라고 한다. 다음은 제스처 이벤트를 통해 처리할 수 있는 이벤트 이다.

메서드 이벤트 유형
onDown() 화면이 눌렸을 경우
onShowPress() 화면이 눌렸다 떼어지는 경우
onSingleTapUp() 화면이 한 손가락으로 눌렸다 떼어지는 경우
onSingleTapConfirmed() 화면이 한 손가락으로 눌려지는 경우
onDoubleTap() 화면이 두 손가락으로 눌려지는 경우
onDoubleTapEvent() 화면이 두 손가락으로 눌려진 상태에서 떼거나 이동하는 등 세부적인 액션을 취하는 경우
onScroll() 화면이 눌린 채 일정한 속도와 방향으로 움직였다 떼는 경우
onFlign() 화면이 눌린 채 가속도를 붙여 손가락을 움직였다 떼는 경우
onLongPress() 화면을 손가락으로 오래 누르는 경우

 

 

터치 이벤트 처리하기

  • 뷰에 setOnTouchListener()로 리스너 추가
  • Action의 상태에 따라 ACTION_DOWN / ACTION_MOVE / ACTION_UP 등의 상태가 int 타입으로 존재한다.
View view = findViewById(R.id.view1);
view.setOnTouchListener(new View.OnTouchListener() {
    @Override
    public boolean onTouch(View view, MotionEvent motionEvent) {
        int action = motionEvent.getAction();

        float curX = motionEvent.getX();    // 현재 이벤트 발생한 x좌표
        float curY = motionEvent.getY();    // 현재 이벤트 발생한 y좌표

        if (action == MotionEvent.ACTION_DOWN){
            println("손가락 눌려짐: " + curX + ", " + curY);
        }
        else if (action == MotionEvent.ACTION_MOVE){
            println("손가락 이동중: " + curX + ", " + curY);
        }
        else if (action == MotionEvent.ACTION_UP){
            println("손가락 떼짐: " + curX + ", " + curY);
        }

        return true;
    }
});

 

 

제스처 이벤트 처리하기

  • GestureDetector 이용해 자동으로 계산된 속도, 움직인 거리 값을 알 수 있다..
detector = new GestureDetector(this, new GestureDetector.OnGestureListener() {
    @Override
    public boolean onDown(MotionEvent motionEvent) {
        println("onDown() 호출");
        return true;
    }

    @Override
    public void onShowPress(MotionEvent motionEvent) {
        println("onShowPress() 호출");
    }

    @Override
    public boolean onSingleTapUp(MotionEvent motionEvent) {
        println("onSingleTapUp() 호출");
        return true;
    }

    @Override
    public boolean onScroll(MotionEvent motionEvent, MotionEvent motionEvent1, float v, float v1) {
        println("onScroll() 호출: " + v + ", " + v1);
        return true;
    }

    @Override
    public void onLongPress(MotionEvent motionEvent) {
        println("onLongPress() 호출");
    }

    @Override
    public boolean onFling(MotionEvent motionEvent, MotionEvent motionEvent1, float v, float v1) {
        println("onDown() 호출: " + v + ", " + v1);
        return true;
    }
});

View view2 = findViewById(R.id.view2);
view2.setOnTouchListener(new View.OnTouchListener() {
    @Override
    public boolean onTouch(View view, MotionEvent motionEvent) {
        detector.onTouchEvent(motionEvent);
        return true;
    }
});

 

 

키 이벤트 처리하기

  • 키 입력은 onKeyDown() 메서드를 재정의하여 처리할 수 있다.
    boolean onKeyDown(int keyCode, KeyEvent event)

대표적인 keyCode

 

 

화면 방향 전환 이벤트 처리하기

단말 방향을 바꿨을 때, 서로 다른 XML 레이아웃을 보여주어야 하기 때문에 액티비티는 메모리에서 없어졌다가 다시 만들어지게 된다.

단말 방향에 따라 서로 다른 XML을 보여주기 위해서는 res 폴더 아래 layout-land라는 새로운 디렉토리를 만들어야 한다.

 

하지만 새로 만든 폴더는 프로젝트 창에 보이지 않는다. 그 이유는 왼쪽 프로젝트 창은 실제 폴더나 파일을 보여주는 것이 아니라 필요한 정보만 정리해서 보여주는 것이기 때문이다. 그렇기 때문에 왼쪽 프로젝트 창 상단에서 [Project] 탭을 선택해서 찾으면 새로 만든 폴더를 확인할 수 있다.

이제 activity_main.xml 파일을 layout 디렉토리와 layout-land 디렉토리에 각각 만들고, 단말 방향에 맞는 레이아웃을 설정하면 된다.

 

위에서 언급했듯이 단말 방향이 바뀌면 액티비티가 메모리에서 없어졌다가 새로 만들어지기 때문에 이 경우에 액티비티 안에 선언해 두었던 변수 값이 사라지므로 변수의 값을 저장했다가 다시 복원하는 방법이 있어야 한다.

이런 문제를 해결하기 위해 onSaveInstanceState 콜백 메서드가 제공된다. 이 메서드는 액티비티가 종료되기 전의 상태를 저장해서 onCreate() 메서드가 호출될 때 전달되는 번들 객체로 복원할 수 있다.

 

변수 값 저장하는 코드

@Override
protected void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    
    outState.putString("name", name);	// name 변수 값 저장
}

 

변수 값 가져오는 코드

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    
    String name;
    
    if (savedInstanceState != null) {
    	name = savedInstanceState.getString("name");	// name 변수 값 복원
    }
}

 

 

토스트, 스낵바, 알림 대화상자

토스트

Toast.makeText(this, "토스트 메시지", Toast.LENGTH_SHORT).show();

 

스낵바

  • 스낵바를 사용하기 위해서는 외부 라이브러리를 추가해야 한다.
  • File > Project Structure ... > Dependencies > [+]버튼 누른 뒤 Library dependency
  • com.android.support:design 항목을 찾아서 추가한다.
Snackbar.make(this, "스낵바 메시지", Snackbar.LENGTH_LONG).show();

 

알림 대화상자

  • 사용자에게 확인을 받거나 선택하게 할 때 사용한다.
  • 사용자의 입력을 받기보다는 일방적으로 메시지를 전달하는 역할을 주로 하며 '예', '아니오'와 같은 전형적인 응답을 처리한다.
  • 알림 대화상자는 AlertDialog 객체를 만들고 show 메서드를 이용해 화면에 표시한다.
  • 알림 대화상자에는 타이틀, 안내 메시지, 아이콘 그리고 예, 아니오 버튼 등을 설정할 수 있다.
AlertDialog.Builder builder = new AlertDialog.Builder(this); 

builder.setTitle("안내");
builder.setMessage("종료하시겠습니까?");
builder.setIcon(android.R.drawable.ic_dialog_alert);

// 긍정적인 답의 이벤트 설정
builder.setPositiveButton("예", new DialogInterface.OnClickListener() { 
    public void onClick(DialogInterface dialog, int which) {
        String message = "예 버튼이 눌렸습니다. ";
        textView.setText(message);
    }
});

// 중립적인 답의 이벤트 설정
builder.setNeutralButton("취소", new DialogInterface.OnClickListener() { 
    public void onClick(DialogInterface dialog, int which) {
        String message = "취소 버튼이 눌렸습니다. ";
        textView.setText(message);
    }
});

// 부정적인 답의 이벤트 설정
builder.setNegativeButton("아니오", new DialogInterface.OnClickListener() { 
    public void onClick(DialogInterface dialog, int which) {
        String message = "아니오 버튼이 눌렸습니다. ";
        textView.setText(message);
    }
});

AlertDialog dialog = builder.create(); 
dialog.show();

 

 

 

 

[인플레이션]

XML 레이아웃과 소스 코드 매칭

안드로이드에서 하나의 화면(액티비티)은 하나의 xml 파일과 하나의 java 파일로 이루어져 있어서 xml파일은 화면의 레이아웃을 담당하고 java파일은 화면의 동작을 담당한다.

이렇게 분리된 XML 파일의 내용을 소스 코드에서 인식할 수 있어야 하는데, 그 과정이 인플레이션이다.

만약 XML 레이아웃에 버튼이 있다면 이 버튼을 소스 코드에서도 사용할 수 있도록 하는 것이다.

 

setContentView의 파라미터로 해당 XML 레이아웃 파일을 지정해주면 내부적으로 인플레이션 과정이 진행된다.

XML 레이아웃 파일 안에 들어있는 뷰 태그들을 이용해 뷰 객체를 메모리에 만드는 과정이 인플레이션이다.

 

이렇게 XML 레이아웃 파일의 내용이 메모리에 객체로 만들어지면 소스 코드에서는 그 객체들을 찾아 사용할 수 있다.

객체를 찾을 때는 findViewById 메서드를 이용할 수 있으며 XML 레이아웃에 뷰를 추가할 때 넣어둔 id 속성의 값을 파라미터로 사용한다.

 

 

뷰를 위한 레이아웃 인플레이터

화면 전체를 나타내는 액티비티는 setContentView 메서드를 이용해 XML 레이아웃을 인플레이션할 수 있지만, 액티비티 레이아웃XML 파일 안에 포함되지 않는 뷰의 경우에는 setContentView 메서드를 통해 인플레이션 할 수 없다.

(setContentView 메서드는 액티비티를 위해 만들어 놓은 것이기 때문)

 

그렇기 때문에 뷰의 경우에는 직접 인플레이션을 해야 한다.

레이아웃 인플레이터 객체는 시스템 서비스 객체로 제공되기 때문에 getSystemService 메서드를 이용해 참조할 수 있다.

 

그리고 뷰 객체가 있으면 그 뷰 객체에 인플레이션한 결과물을 설정하게 되는데, 리니어 레이아웃 객체이거나 리니어 레이아웃을 상속한 뷰 객체가 container라는 이름으로 만들어져 있다면 다음과 같은 코드를 이용해 레이아웃 인플레이션을 진행할 수 있다.

LayoutInflater inflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE);
inflater.inflate(R.layout.sub1, container, true); 
// res/layout에 있는 sub1.xml을 container에 즉시(true) 메모리 객체화를 하겠다는 의미

 

XML 레이아웃의 이름은 sub1.xml로 만들어져 있고 이 XML 레이아웃에 들어가 있는 뷰들은 메모리에 객체로 만들어진 후에 container 뷰 객체에 설정된다.

이런 방식은 새로운 뷰를 정의할 때 자주 볼 수 있는데, 동적으로 레이아웃이 변경, 추가되는 경우에도 사용된다.

 

정리하자면.. java 코드에서 버튼을 만들 때는 new Button() 처럼 new 키워드를 이용해 메모리에 올려서 버튼을 만드는 작업을 하지만, xml 파일에 있는 버튼태그는 실제 버튼이 아니기 때문에 new 키워드 같이 메모리에 올리는 작업을 해줘야 한다. 이 작업은 액티비티에서는 setContentView() 메서드로 제공을 하지만, 부분화면과 같은 뷰에서는 직접 인플레이션을 해 주어야 하고 layoutInflater가 이 역할을 수행한다.

 

 

 

 

[리스트 뷰]

"선택 위젯"이라고 불리는 리스트뷰/그리드뷰/스피너를 사용할 때에는 실제 데이터가 들어있는 원본 데이터가 따로 존재한다.

원본 데이터와 선택 위젯 사이에 어댑터가 존재해서 이 어댑터가 아이템에 대한 뷰와 데이터를 관리한다.

 

뷰를 만드는 과정

  1. 각각에 아이템에 대한 뷰를 정의 (xml, java)
  2. 어댑터 안에 데이터 넣기
  3. 어댑터의 getView() 메서드를 이용해 위에서 정의한 뷰를 아이템으로 만들어서 안에 데이터를 설정해준 뒤 리턴

어댑터는 일반적으로 커스터마이징하기 쉽도록 직접 정의하는 것이 좋다.

선택 위젯의 어댑터는 일반적으로 BaseAdapter를 상속 받아 만든다.

 

 

리스트 뷰를 만들 때 필요한 파일 및 기능 정리

먼저, xml파일에 리스트뷰를 선언한다. 이 리스트뷰는 껍데기일 뿐 실제로 하는 일은 어댑터에 정의해야 한다.

<ListView
        android:id="@+id/listView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

 

그리고 리스트뷰에 보여질 하나의 아이템에 대한 xml 레이아웃을 정의한다. 이를 item.xml이라고 하자. 예를 들어 하나의 아이템은 이미지, 이름, 전화번호를 보여준다고 하자.

 

안드로이드는 xml 파일과 java 파일이 하나의 쌍을 이뤄야 하기 때문에 xml을 인플레이션 해 줄 java 파일을 정의한다. 이를 ItemView.java 라고 하면 이 안에서는 생성자 2개를 만들어 주고, 각각의 생성자에서 xml 파일을 직접 인플레이션 해 준다. (액티비티가 아닌 부분 화면이기 때문에 setContentView() 메서드를 사용할 수 없기 때문.) 이 때 생성자를 2개 만들어 주는 이유는 xml과 java 소스 파일이 분리되어 있어 아이템을 xml에서 태그로 추가해서 만들 때와 java에서 만들 때 각각 호출하는 생성자가 다르기 때문에 필수 생성자가 2개 있어야 한다. 인플레이션이 되어 뷰 객체들이 메모리에 올라왔으면, 이를 findViewById() 메서드를 통해 각각의 뷰들을 찾아 주고, 그 뷰에 데이터를 넣어줄 setter 메서드를 정의한다.

public class ItemView extends LinearLayout {
    TextView textView;
    TextView textView2;
    ImageView imageView;

    public ItemView(Context context) {
        super(context);

        init(context);
    }

    public ItemView(Context context, AttributeSet attrs) {
        super(context, attrs);

        init(context);
    }

    // 새롭게 만든 singer_item.xml의 뷰들을 인플레이션 해 주는 역할
    private void init(Context context){
        LayoutInflater inflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        inflater.inflate(R.layout.singer_item, this, true);

        textView = findViewById(R.id.textView);
        textView2 = findViewById(R.id.textView2);
        imageView = findViewById(R.id.imageView);
    }

    public void setItem(Item item) {
        textView.setText(item.getName());
        textView2.setText(item.getMobile());
        imageView.setImageResource(item.getResId());
    }
}

 

이제 아이템 하나에 대한 java 파일을 정의한다. 이를 Item.java라고 하면, 이 자바 파일에는 하나의 아이템이 가지고 있을 데이터를 멤버 변수로 갖는다. 멤버 변수를 초기화 할 생성자와 getter 메서드를 정의한다.

public class Item {
    private String name;
    private String mobile;
    private int resId;

    public Item(String name, String mobile, int resId) {
        this.name = name;
        this.mobile = mobile;
        this.resId = resId;
    }

    public String getName() { return name; }
    public String getMobile() { return mobile; }
    public int getResId() { return resId; }
}

 

그 다음에는 BaseAdapter를 상속 받은 어댑터 클래스를 정의한다. 그 안에는 데이터를 관리할 ArrayList<Item>을 만들어 둔다. BaseAdapter의 implement해야 하는 메서드는 다음과 같다.

  • getCount() - 아이템의 개수 반환
  • getItem(int i) - i 번째 아이템 반환 
  • getItemId(int i) - i 번째 아이템의 id 반환
  • getView(int i, ...) - 위에서 정의한 i번째 뷰를 아이템으로 만들어서 안에 데이터를 설정해준 뒤 반환

여기서 가장 중요한 메서드가 getView()이다. item.xml 레이아웃에 데이터를 넣어 하나의 아이템을 만든 뒤 반환하는 메서드로, 오버라이딩 해 두면 알아서 데이터로 들어있는만큼 뷰로 만들어서 반환하는 것이다.

또 하나 추가해야 하는 메서드로 addItem()이 있는데, 이는 어댑터에 있는 items에 아이템을 추가하는 메서드이다.

class SingerAdapter extends BaseAdapter {
        ArrayList<Item> items = new ArrayList<Item>();

        public void addItem(Item item){ items.add(item); }

        @Override
        public int getCount() { return items.size(); }

        @Override
        public Object getItem(int i) { return items.get(i); }

        @Override
        public long getItemId(int i) { return i; }

        @Override
        public View getView(int i, View view, ViewGroup viewGroup) {
            ItemView itemView = null;
            // 뷰를 만들 때 뷰 객체가 너무 많이 만들어지면 메모리 소모를 너무 많이 할 수 있기 때문에
            // 기존의 화면에 보여지지 않는 view가 있다면 이를 재사용하도록 만들어주는 코드.
            if(view == null) itemView = new ItemView(getApplicationContext());
            else itemView = (ItemView) view;

            Item item = items.get(i);
            itemView.setItem(item);

            return itemView;
        }
    }

 

마지막으로, 어댑터에 아이템을 넣고 처음에 만들어 놓은 껍데기 리스트뷰에 어댑터를 설정하면 된다. 아이템을 클릭했을 때 어떤 아이템이 선택되었는지는 AdapterView.OnItemClickListener() 인터페이스에 오버라이드 되어 있는 onItemClick() 메서드를 사용하면 된다.

ListView listView = findViewById(R.id.listView);

singerAdapter = new SingerAdapter();
singerAdapter.addItem(new Item("소녀시대", "010-1000-1000", R.drawable.image1));
singerAdapter.addItem(new Item("걸스데이", "010-2000-2000", R.drawable.image1));
singerAdapter.addItem(new Item("여자친구", "010-3000-3000", R.drawable.image1));
singerAdapter.addItem(new Item("에이프릴", "010-4000-4000", R.drawable.image1));

listView.setAdapter(singerAdapter);
listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
    @Override
    public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
        Item singerItem = (Item) singerAdapter.getItem(i);

        Toast.makeText(getApplicationContext(), "선택: " + singerItem.getName(), Toast.LENGTH_SHORT).show();
    }
});

 

한가지 더 알아야 하는 것이 있는데, 나중에 어댑터에 아이템을 추가하는 경우에는 notifyDataSetChanged() 메서드를 호출해주어야 한다는 것이다. 아래 코드는 버튼을 눌렀을 때 어댑터에 아이템을 추가하는 코드이다.

Button button = findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        EditText editText = findViewById(R.id.editText);
        EditText editText2 = findViewById(R.id.editText2);
        
        singerAdapter.addItem(new Item(editText.getText().toString(), editText2.getText().toString(), R.drawable.image1));
        singerAdapter.notifyDataSetChanged();
    }
});

 

 

 

 

[나인패치 이미지]

안드로이드에서는 버튼이나 텍스트뷰의 배경으로 이미지를 설정하면 그 이미지는 자동으로 버튼이나 텍스트뷰의 크기에 맞춰진다.

이 때 이미지의 크기가 늘어나면서 아래 그림과 같이 일부분이 깨져 보이는 문제가 생기기도 한다.

나인패치 이미지는 이런 문제를 해결하기 위해 만들어진 것으로, 원래 이미지보다 한 픽셀씩 크게 만들고 가장자리 픽셀에는 늘어날 수 있는지, 늘어나면 안 되는지를 색상으로 구분하여 넣어준다.

 

그리고 나인패치 이미지라는 이름에 맞게 이미지 파일 이름에 .9 라는 글자를 붙여준다. 예를 들어, person.png 라는 이미지 파일을 나인패치 방식으로 만들었다면 person.9.png 라는 이름으로 바꿔주어야 한다. 이렇게 바꾼 이름은 안드로이드에서 동일하게 R.drawable.person으로 인식한다. 다만 나인패치 이미지라고 인식하기 때문에 이미지를 늘릴 때 특정 부분만 늘려주게 된다. 이렇게 하면 깨질 가능성이 있는 부분은 늘리지 않아서 이미지의 크기가 늘어나더라도 덜 왜곡된 이미지를 보여줄 수 있게 된다.

 

 

 

 

[비트맵 버튼 만들기]

나인패치 이미지를 적용하는 대표적인 경우가 버튼인데, 배경 부분을 이미지로 지정하여 만든 버튼은 아무리 눌러도 이미지의 변화가 없어 사용자가 버튼을 눌렀는지 안눌렀는지 알 수 없다는 문제가 있다. 이 문제를 해결하기 위해 버튼의 상태를 버튼이 눌렸을 때와 떼어졌을 때를 이벤트로 구분하여 처리하는 비트맵 이미지를 이용한다.

 

버튼을 상속한 비트맵 버튼 클래스를 직접 만들어 normal일 경우와 clicked일 경우의 이미지를 표시한다.

비트맵 버튼에서도 리스트 뷰와 마찬가지로, xml과 java 소스 파일이 분리되어 있어 버튼을 xml에서 태그로 추가해서 만들 때와 java에서 만들 때 각각 호출하는 생성자가 다르기 때문에 비트맵 버튼의 필수 생성자가 2개 있어야 한다.

 

비트맵 버튼을 만든 뒤 onTouchEvent()를 버튼 안에 추가해서 만들 수도 있다. 이 때, 동작을 설정한 뒤에는 다시 그려주어야 하는데, 이 때 사용하는 메서드가 invalidate()이다.

public boolean onTouchEvent(MotionEvent event) {
	super.onTouchEvent(event);

	int action = event.getAction();

	switch (action) {
    	case MotionEvent.ACTION_DOWN:
        	setBackgroundResource(this.iconClicked);
        	break;
    	case MotionEvent.ACTION_UP:
        	setBackgroundResource(this.iconNormal);
        	break;
	}

	invalidate();

	return true;
}

 

+ java에서는 text size를 픽셀 단위로만 사용할 수 있기 때문에 dp로 설정하고자 할 때는 아래와 같은 방법을 사용해야 한다.

  1. res/values에 dimens.xml 파일을 만든다.
  2. xml파일에 <resource> 아래에 <dimen name="text_size”>16dp</dimen> 코드를 추가
  3. java 소스파일에서 float textSize = getResources().getDimension(R.dimen.text_size)로 해당 dp에 대한 픽셀 값을 가져온다.

(참고) 여기서 getResources()는 res 폴더 아래에 있는 리소스에 접근하는 함수이다.

 

 

+ 뷰를 상속해서 새로운 뷰를 만든 경우에는 XML 레이아웃에 추가할 때 패키지 명까지 같이 넣어줘야 한다.

<org.techtown.bitmapwidget.BitmapButton
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
/>

 

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
글 보관함