상세 컨텐츠

본문 제목

자바- 리스트 컨트롤 구현 모음들

JAVA/JAVA UI

by AlrepondTech 2020. 9. 15. 15:00

본문

반응형
728x170

 

 

=================================

=================================

=================================

 

 



츨처: http://javafreak.tistory.com/220

ava Tutorial 에 나오는 JList 의 모습은 왼쪽과 같은데, 분류상 Component 로 되어있기는 하나 동작 방식을 보면 Container 와 거의 흡사하다.

Container와 Component의 가장 큰 차이점이라면 자식 컴포넌트를 포함하는 기능이지만, JList, JTable, JTree같은 컴포넌트는 자체적으로 안에 다른 컴포넌트를 포함하는 식으로 구현되어 있으니 굳이 이름을 붙이자면 TableLayout, TreeLayout, ListLayout이라고 할 만 하다.

[그림1] 은 JList 안에 JLabel 컴포넌트를 그려넣은 화면이지만 JList 안에 그려넣을 수 있는 컴포넌트가 한정되어있지는 않다. 어떤 컴포넌트이든 ListCellRenderer 를 이용하면 자유롭게 JList 안에 그려넣을 수 있다.

이에 관한 자바 튜토리얼을 보면 짤막한 몇 개의 예제가 있기는 한데, 그다지~ 실용성과는 거리가 먼 무미건조한 예제이다보니 큰 도움은 되지 않는 듯 하다. 또한 가장 중요한 부분인 ListCellRenderer에 대한 설명이 너무 부족하다.

여기서는 파일 다운로드 화면을 JList로 구현한 예제를 설명하려고 한다.


파일 다운로드 화면을 보여줄때 보통 다음과 같은 정보가 포함된다.

* 파일의 이름
* 현재 받은 크기 / 파일의 전체 크기
* 다운로드 속도
* 남은 시간

이것을 DownloadInfo 정도로 이름 붙이면 이것이 JLis 안에 보여질 정보를 담은 인스턴스가 된다. 흔히 말하는 MVC 패턴에서 "모델(Model)"에 해당된다. 어느 스윙 컴포넌트를 보더라도 이 구조를 매우 교조적으로 잘 지키고 있는데 JList도 예외는 아니다.

 

[그림2] 뷰는 신경쓰지 않는다.

모델(Model)은 화면에 보여줄 데이터(E)들을 관리하는 역할을 하는데, MVC 구조에서는 모델에 데이터를 넣으면 사용자의 개입없이 자동으로 뷰(View)가 갱신된다.

새로운 파일을 다운로드 받기 위해서 url을 입력하면, 이에 상응하는 무언가(view)가 화면에 나오기를 기대한다. 그리고 밑단의 소켓 연결을 통해서 데이터가 들어오면 적절히 화면이 갱신될텐데, 이런 일련의 과정(데이터 변경->화면 개인)이 사용자의 전반적인 개입 없이 "모델에 데이터를 넣고 수정하고 삭제하는" 행위만으로 이루어지는 것이 MVC 구조의 장점이다.

1. ListModel 구현

이를 위해서 가장 먼저 할 일은 javax.swing.ListModel 을 구현한 인터페이스를 작성하는 것이다. 각각의 다운로드 작업을 저장, 관리하는 역할을 하는데 사용자가 가장 빈번하게 다루는 대상이다.(가장 중요!)

오히려 java.swing.JList 는 손댈 일이 별로 없을 때가 많다. 왜냐하면 실제 작업은 모델을 통해서 데이터를 관리하는 것이고 화면은 그 결과로 보여지는 것이기 때문이다. javax.swing.JList 는 ListCellRenderer, ListModel, ListUI, 그리고 각종 이벤트 리스너들을 한데 모아서 조립, 초기화하는 창구 역할에 불과하다.(Facade 패턴이라고 하나??)

JList를 쓴다고 할 때에는 "ListModel을 통해서 보여줄 데이터를 관리하고 ListCellRenderer로 화면에 보여준다"로 받아들이면 된다. 단순히 JList 인스턴스 하나 초기화해서 할 일이 그리 많지 않고 그럴 용도로 만들어진 것이 아니다.(ListModel 과 ListCellRenderer 가 포인트!!)

javax.swing.ListModel 에는 네 개의 메소드가 선언되어 있다.

  /** 
   * Returns the length of the list.
   * @return the length of the list
   */

  int getSize();

  /**
   * Returns the value at the specified index.  
   * @param index the requested index
   * @return the value at index
   */

  Object getElementAt(int index);

  /**
   * Adds a listener to the list that's notified each time a change
   * to the data model occurs.
   * @param l the ListDataListener to be added
   */
  
  void addListDataListener(ListDataListener l);

  /**
   * Removes a listener from the list that's notified each time a 
   * change to the data model occurs.
   * @param l the ListDataListener to be removed
   */
  
  void removeListDataListener(ListDataListener l);

두 개의 메소드는 이벤트 리스너를 등록, 삭제할때 쓰는데 하나의 모델에 두 개 이상의 뷰를 연결시키는게 아니라면 손댈 일이 거의 없다.(예를 들자면 똑같은 데이터를 서로 다른 형태로 보여주고자 할 때 하나의 모델에 두개 이상의 뷰를 물려서(?) 데이터만 넣으면 뷰들이 알아서 갱신되도록 할 수 있다.) 나머지 두 개의 메소드인 getSize()와 getElementAt(i) 가 외부의 컴포넌트들이 현재 모델에 들어있는 정보를 획득하는 통로가 된다.

실제 JList의 내부 구현을 보면 사용자가 ListModel 에 element를 하나 넣으면(여기서는 DownloadInfo 인스턴스) 등록된 이벤트 리스너들에게 새로 등록된 element의 index 위치 값만 알려준다. 그러면 리스너(구체적으로 ListUI 인스턴스)들은 이 index 값을 가지고 다시 ListModel.getElementAt(index) 를 호출해서 새로 추가된 element를 가져다가 ListCellRenderer 에게 전달해준다.

그리고 ListCellRenderer 가 아래와 같이 메소드의 파라미터(Object value) 로 전달받는다.

public interface ListCellRenderer
{
    Component getListCellRendererComponent(
        JList list,
        Object value, // DownloadInfo 인스턴스가 이쪽으로 들어온다.
        int index,
        boolean isSelected,
        boolean cellHasFocus);
}

Object value, 로 한 것은 사용자가 어떤 모델 데이터를 이용할지 특정지을 수 없기 때문이다. 여기서는 DownloadInfo 인스턴스를 다루므로 ListCellRenderer 인터페이스를 구현할 때 아래와 같이 캐스팅 해서 사용하게 된다. 거의 대부분 이런 식으로 운용된다.

DownloadInfo info = (DownloadInfo) value;


이런 호출 흐름을 완성하기 위해서 가장 먼저 해야할 일이 바로 ListModel 인터페이스를 구현하는 일인데 스윙 api 에서는 사용자를 위해서 매우 간단한 기본 구현체인 DefaultListModel을 제공하고 있다. 단순히 화면에 모델 데이터를 뿌려주기만 할 것이면 이것만으로도 충분하지만, 예를 들어서 모델이 들어있는 데이터들을 정렬하는 등 기능을 추가하려면 기본 구현체를 상속해서 정렬 기능을 삽입하면 된다.

protected ArrayList<DownloadInfo> elements = new ArrayList<DownloadInfo>();
...
public void sortByRemainingTime(){

    Comparator comparator = RemainingTimeComparator();
    // elements 리스트를 정렬한다.
    ....
    fireContentsChanged(this, 0, elements.size()); // 반드시 호출해준다.
}

위에서 fireContentsChanged 를 호출할 때 누가 영향을 받을지는 모르지만(?) 리스너가 있다면 현재 정렬을 통해서 모델 데이터에 변경이 생겼음을 알려주는 것이다.(정렬해놓고 알리지 않으면 화면에서는 아무런 변화도 일어나지 않을 것이다.)

element를 새로 하나 넣었을 때, 또는 element를 빼냈을때 또는 위처럼 정렬했을때 모델의 상태에 변화가 생길때마다 리스너에게 이를 알려줘야만 한다. 그래서 API 문서를 보면 다음과 같이 꼭 호출하라는 fire....() 메소드들이 있다.

 

must-calling

index 값은 변경이 일어난 구간의 범위를 설정할 때 쓰이는데 추가, 삭제처럼 한군데서만 변화가 일어났다면 index를 동일한 값으로 주면 된다. 유의할 점은 위에서 fire...() 를 호출할때 건네주는 index가 javax.swing.ListModel 인터페이스에 선언된 getElementAt(index); 에서 사용될 값이라는 점이다. 2번째 element에서 변경이 일어났는데, index 3을 전달해주면 화면에 엉뚱한게 튀어나온다.(이거 흔한 실수)

직접 javax.swing.AbstractListModel을 상속해서 멋드러진 ListModel을 작성했다면 다음과 같은 테스트 코드로 원하는대로 실행되는지 확인하는 것도 좋다.

public class Test_DownloadListModel extends TestCase {
    private DownloadListModel model ;
    private TestListener listener ;
    protected void setUp() throws Exception {
        super.setUp();
        // 모델을 만들고 테스트용 리스너를 하나 만들어서 등록한다.
        model = new DownloadListModel();
        listener = new TestListener();
        model.addListDataListener(listener);
    }
    
    public void test_list_add () throws Exception {
        Job job = new Job();
        job.setId("http://doc.google.ecom");
        // 여기서 데이터를 넣으면
        // 아래의 리스너가 호출된다.
        model.addRequest(job);
    }
    class TestListener implements ListDataListener {

        @Override
        public void contentsChanged(ListDataEvent e) {
            // TODO Auto-generated method stub
        }

        @Override
        public void intervalAdded(ListDataEvent e) {
            // 위치 확인
            assertEquals(0, e.getIndex0());
            assertEquals(0, e.getIndex1());
            ListModel model = (ListModel)e.getSource();
            // 데이터 확인
            assertEquals(job, model.getElementAt(e.getIndex0()));
        }

        @Override
        public void intervalRemoved(ListDataEvent e) {
            // TODO Auto-generated method stub    
        }    
    }
}

<데이터를 추가> 하는 부분이 어떤 경로를 통해서 <리스너 호출>로 이어지는지는 알 수 없으나 어쨋든 잘 작동되는 것을 확인하면 충분하다. (이런걸 블랙박스 테스트라고 하나?) 위와같은 테스트를 통해서 내가 작성한 ListModel이 제대로 동작함을 확인하고 그 다음 단계로 화면에 출력하는 ListCellRenderer로 이어진다.

 

 

 

=================================

=================================

=================================

 

 




전편에서는 ListModel에 대해서 다뤘는데, 모델 구현이 어느정도 마무리되면 모델의 데이터를 화면에 보여줄 뷰를 구성할 수 있다. 여기에 참여하는 컴포넌트 중에 사용자가 직접 구현해야 하는 것은 javax.swing.ListCellRenderer 이다.

ListCellRenderer는 "도장"과 같은 역할을 한다. 도장을 하나 파 놓고 계약서든 어디든 꾹꾹 눌러 찍기만 하면 똑같은 모양을 얼마든지 찍어낼 수 있는 것처럼, ListCellRenderer 도 기본적으로 도장과 같이 작동한다.

사용자가 모델에 관리하려는 데이터 인스턴스를 넣으면 ListModel이 등록된 리스너들에게 "새로운 element가 추가되었다" 라고 메세지를 보내고, 리스너들은 이 메세지에 포함된 index 값으로 다시 모델에 등록된 "새로 추가된 데이터 인스턴스"를 얻어낸다.(수정이든 삭제든 모두 이런 식으로 이루어진다.)

그리고 이렇게 얻어낸 인스턴스를 ListCellRenderer 구현체에 전달하면, 데이터 인스턴스를 그려줄 적당한 컴포넌트를 하나 반환하는데(이 부분을 사용자가 구현해야한다), 실제 그리는 역할을 전담하는 ListUI 구현 내용을 보면 ListCellRenderer 가 반환하는 컴포넌트를 CellRendererPane 에 전달해서 화면에 똑같이 그려넣는다.

 

[그림1]BasicListUI.paintCell 메소드

paintCell 메소드의 코드 마지막 줄에서 rendererPane.paintComponent(g, renderereComponent, ....) 가 호출되면 저기서 진짜 "그리기" 작업이 이루어진다.(말 그대로 도장 찍듯이 그리기만 한다. 이 말의 의미는 밑에서 추가 설명한다.)

ListModel 구현체에 데이터 인스턴스를 넣는 것에서 실제 화면에 그려지기까지 기나긴 과정 중에서 사용자가 개입해야하는 부분이 바로 ListCellRenderer 를 구현해서 "rendererComponent"를 정확하게 전달해주는 것이다.

아래와 같이 파일 다운로드 화면에서, "redererComponent"가 "DownloadPanel 컴포넌트에 해당한다.

 

[그림2]각각의 다운로드 화면은 도장 자국.

ListCellRenderer를 구현한 코드는 대략 아래와 같은데..

-------------------------------------------------------------------------------------------------------------

public class DownloadPanelRenderer implements ListCellRenderer {
    private DownloadPanel panel ;
    public DownloadPanelRenderer() {
        panel = new DownloadPanel(); // 도장을 하나 파놓자!!
    }
    @Override
    public Component getListCellRendererComponent(JList list, Object value,
            int index, boolean isSelected, boolean cellHasFocus) {
        //DownloadPanel panel = new DownloadPanel();
        Job job = (Job) value;
        DownloadInfo info = job.getInfo();
        Color bgColor = isSelected ? Color.yellow : Color.white;
        String downloadSpeed = "";
        String remainingTime = "";
        ImageIcon stateIcon = null;
        boolean barVisible = true;
        long currentSize = info.getCurrentSize();
        long length = info.getLength();
        boolean unknown = length < 0 ;
        
        while ( length > Integer.MAX_VALUE ){
            length /= 2;
            currentSize /=2;
        }
        
        
        if ( job.isCancelled()) {
            stateIcon = UIRegistry.getIcon("img.icon.gray");
        } else if ( job.isFinished()){
            stateIcon = UIRegistry.getIcon("img.icon.gray");
            barVisible = false;
        } else {
            stateIcon = UIRegistry.getIcon("img.icon.blue");
        }
        
        if( unknown ){
            downloadSpeed = makeString(info.getCurrentSize()) + " / " + "Unknown" + 
                    " (" + info.getCurrentSpeed() + "KB/sec)";
            panel.updateBar("file size unknown", 10, 10, false);
            remainingTime = "Unknown";
        } else {
            downloadSpeed = makeString(info.getCurrentSize()) + " / " + 
                    makeString(info.getLength()) + 
                    " (" + info.getCurrentSpeed() + "KB/sec)";
            panel.updateBar("" + info.getCurrentSize()/info.getLength(), 
                    (int)currentSize, 
                    (int)info.getLength(), true);
            
            remainingTime = makeTimeString(info.getRemainingTime());
        }
        
        panel.setStateIcon(stateIcon);
        panel.setBackground(bgColor);
        panel.setFileName(job.getFileName());
        panel.setDownloadSpeed(downloadSpeed);
        panel.setRemainingTime(remainingTime);
        panel.setBarVisible(barVisible);
        
        return panel;
    }
----------------------------------------------------------------------------------------------------

DownloadPanel 인스턴스를 하나 만들어놓고(도장 하나 파 놓는 것처럼) getListCellRendererComponent 가 호출될때마가 설정 정보만 데이터 인스턴스(Job, DownloadInfo) 에 맞게 살짝 바꿔서 반환한다. 여기서 반환하는 값이 [그림1]에서 rendererComponent 로 참조된다.(그리고 rendererPane이 이걸 가지고 그리는 작업으로 넘어감..)

여기서 계속해서 "도장자국"을 얘기하는 것은, [그림2]에 보듯이 세 개의 다운로드 컴포넌트가 보이는데, 저것들은 실제 DownloadPanel 인스턴스가 3개 들어있는게 아니라, 하나의 DownloadPanel 인스턴스를 가지고  상태값만 적절히 바꿔가며 도장 찍듯이 redererPane 에 그려넣은 "자국"에 불과하다.

ListCellRenderer 구현을 아래와 같이 바꾸더라도 밑단에서 그리는 작업을 하는 ListUI 의 기본적인 작업 방식이 "도장 찍기" 방식이라 하부 구현을 직접 바꾸지 않는 한 별 소용이 없다.

        DownloadPanel panel = new DownloadPanel(); // 객체를 아예 새로 만들어도
        ...  // 별 소용 없다.
        return panel;

오히려 위처럼 메소드 안에서 매번 객체를 만들면, ProgressBar가 진행할때마다 엄청나게 많은 DownloadPanel 인스턴스가 생성되고 곧바로 소멸되는 화려한 광경을 목격하게 된다.(어디 갈때마다 도장 하나 파고 한 번 쓰고 버리고 또 도장 새로 파고 한 번 쓰고 버리는 식..)

만일 저 화면에서 DownloadPanel의 오른쪽에 버튼을 하나 넣고 리스너를 붙여서 <일시 정지 기능>을 구현한다면.. JList 화면을 눌러봐도 아무 반응이 없다.(진짜로 그림의 떡일 뿐)

JList는 기본 구현이 "단순히 보여주기"위한 기능에 맞춰져 있어서 마우스 클릭과 같이 사용자 행위에 적절히 반응하도록 작성하려면 많은 장애물이 도처에 널려있다.

이렇게 "도장찍기"식의 ListUI 구현이 마음에 안든다면 직접 BasicListUI 를 상속해서 새로운 ui 그리는 기능을  만드는 것도 가능하지만, 차라리 BoxLayout 을 이용하면 JList와 얼추 비슷한 기능을 구현할 수가 있다.(ListModel 구현을 그대로 가져다가 쓸 수도 있고..)

정리하면 JList를 사용할 때 다음과 같은 단계를 밟는다.

1. DownloadInfo와 같은 데이터 클래스 작성

2. 데이터 클래스의 인스턴스를 관리할 ListModel 구현체 작성 후 테스트. - 관리가 단순하다면 기본 구현인 DefualtListModel을 그대로 가져다 사용

3. ListCellRenderer 구현체 작성

그리고 아래와 같이 JList를 초기화한다.

    DownloadPanelRenderer renderer = new DownloadPanelRenderer();
    DownloadListModel model = new DownloadListModel();
    JList list = new JList();
    list.setCellRenderer(renderer);
    list.setModel(model);
    ....
    Job job = new Job(); // 데이터 인스턴스 하나 생성.
    model.addJob(job); // 화면 출력을 촉발함.
    ....
    model.removeJob(job); // 화면에서 사라짐.

 

 

 

=================================

=================================

=================================

 

 








//자바 리스트 구현 모음





/*
Java Swing, 2nd Edition
By Marc Loy, Robert Eckstein, Dave Wood, James Elliott, Brian Cole
ISBN: 0-596-00408-7
Publisher: O'Reilly 
*/
// SwingListExample.java
//An example of the JList component in action. This program uses a custom
//renderer (BookCellRenderer.java) to show a list of books with icons of their
//covers.
//

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.ListCellRenderer;

public class SwingListExample extends JPanel {

  private BookEntry books[] = {
      new BookEntry("Ant: The Definitive Guide", "1.gif"),
      new BookEntry("Database Programming with JDBC and Java",
          "2.gif"),
      new BookEntry("Developing Java Beans", "3.gif"),
      new BookEntry("Developing JSP Custom Tag Libraries",
          "4.gif"),
      new BookEntry("Java 2D Graphics", "4.gif"),
      new BookEntry("Java and XML", "5.gif"),
      new BookEntry("Java and XSLT", "1.gif"),
      new BookEntry("Java and SOAP", "2.gif"),
      new BookEntry("Learning Java", "3.gif") };

  private JList booklist = new JList(books);

  public SwingListExample() {
    setLayout(new BorderLayout());
    JButton button = new JButton("Print");
    button.addActionListener(new PrintListener());

    booklist = new JList(books);
    booklist.setCellRenderer(new BookCellRenderer());
    booklist.setVisibleRowCount(4);
    JScrollPane pane = new JScrollPane(booklist);

    add(pane, BorderLayout.NORTH);
    add(button, BorderLayout.SOUTH);
  }

  public static void main(String s[]) {
    JFrame frame = new JFrame("List Example");
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setContentPane(new SwingListExample());
    frame.pack();
    frame.setVisible(true);
  }

  // An inner class to respond to clicks on the Print button
  class PrintListener implements ActionListener {
    public void actionPerformed(ActionEvent e) {
      int selected[] = booklist.getSelectedIndices();
      System.out.println("Selected Elements:  ");

      for (int i = 0; i < selected.length; i++) {
        BookEntry element = (BookEntry) booklist.getModel()
            .getElementAt(selected[i]);
        System.out.println("  " + element.getTitle());
      }
    }
  }
}

class BookEntry {
  private final String title;

  private final String imagePath;

  private ImageIcon image;

  public BookEntry(String title, String imagePath) {
    this.title = title;
    this.imagePath = imagePath;
  }

  public String getTitle() {
    return title;
  }

  public ImageIcon getImage() {
    if (image == null) {
      image = new ImageIcon(imagePath);
    }
    return image;
  }

  // Override standard toString method to give a useful result
  public String toString() {
    return title;
  }
}

class BookCellRenderer extends JLabel implements ListCellRenderer {
  private static final Color HIGHLIGHT_COLOR = new Color(0, 0, 128);

  public BookCellRenderer() {
    setOpaque(true);
    setIconTextGap(12);
  }

  public Component getListCellRendererComponent(JList list, Object value,
      int index, boolean isSelected, boolean cellHasFocus) {
    BookEntry entry = (BookEntry) value;
    setText(entry.getTitle());
    setIcon(entry.getImage());
    if (isSelected) {
      setBackground(HIGHLIGHT_COLOR);
      setForeground(Color.white);
    } else {
      setBackground(Color.white);
      setForeground(Color.black);
    }
    return this;
  }
}





 

=================================

=================================

=================================

 

 

반응형
그리드형


관련글 더보기

댓글 영역