Cecil Lew
네트워크 관리 및 워크플로 처리 같은 대부분의 응용 프로그램 유형에는 다이어그램 작성 인터페이스가 필요합니다. 그러나 Windows 응용 프로그램에서 Visio와 같은 다이어그램 작성 인터페이스를 개발하기는 좀처럼 쉬운 일이 아닙니다. 이 기사에서는 Cecil Lew가 UserControl 및 몇 가지 단순한 클래스를 기반으로 하여 간단한 다이어그램 작성 도구를 빌드하는 방법을 소개합니다.
응용 프로그램에서 다이어그램 작성 기능이 필요한 경우에는 어떻게 하시겠습니까? 이 경우 다음과 같은 네 가지 방법이 있습니다. 즉, 1) 타사 라이브러리를 구입하거나 2) Netron 등의 공개 소스 라이브러리를 사용하거나(확장도 가능함) 3) 응용 프로그램을 Visio와 통합하거나 4) 새 도구를 처음부터 빌드하는 것입니다.
1번이 가장 쉽겠지만 타사 지원에 의존해야 하므로 위험도도 가장 높습니다. 2번도 괜찮은 방법 같지만, 개인적으로 이 방법을 사용해 봤는데 솔직히 25,000줄의 코드로 이루어진 다른 사람이 작성한 라이브러리를 확장하는 것은 쉬운 일이 아닙니다. 3번은 Microsoft Office 커뮤니티에서 많은 지원을 받을 수 있으므로 쉽게 수행할 수 있습니다. 그러나 이 경우 응용 프로그램을 통합하려면 대상 컴퓨터에 올바른 버전의 Visio가 설치되어 있어야 한다는 단점이 있습니다. 여기까지 읽으셨다면 제가 개인적으로 4번을 선호한다는 사실을 눈치채셨을 것입니다. Windows Form 및 GDI+에서는 확대/축소, 변환 및 회전 같은 그리기 및 2D 변환에 뛰어난 기능을 제공합니다.
이 기사에서는 이를 검증하기 위해 개발한 간단한 다이어그램 작성 응용 프로그램에 대해 설명합니다. 먼저 클래스 디자인을 중점적으로 설명한 다음 보다 중요한 기능을 자세히 설명합니다. 이 기사에서 설명하는 응용 프로그램은 C# 및 Windows Form을 사용하여 작성되었습니다. 그림 1은 이 응용 프로그램을 보여 줍니다.
그림 1
왼쪽에는 BlockControl 또는 Connector를 오른쪽의 그리기 영역으로 끌어서 놓을 수 있는 도구 상자가 있습니다. 그리고 컨트롤의 텍스트를 두 번 클릭하여 수정할 수 있습니다. 마지막으로 Connector 핸들을 끌어 BlockControl 주위의 핸들 중 하나에 부착할 수 있습니다.
물론 이 응용 프로그램에서 유용한 기능을 많이 제공하는 것은 아닙니다. 그러나 이 응용 프로그램을 통해 다이어그램 작성 프레임워크의 기초가 되는 상속, 컴퍼지션 및 다양한 C# 구문을 사용하는 방법에 대한 기본 개념을 파악할 수 있습니다.
클래스 디자인
DiagramLib 클래스 라이브러리에는 8개의 클래스가 포함됩니다. 표 1에서는 이러한 클래스 및 해당 용도를 나열합니다.
표 1. DiagramLib 라이브러리의 8개 클래스
클래스 |
목적 |
Draggable |
이 프로젝트에서 발생하는 대부분의 문제는 개체 이동 및 크기 조정과 관련된 것입니다. 이 일반 Draggable UserControl은 마우스 끌기 동작과 위치 변경을 캡처하기 위한 것입니다. |
LabelBox |
마우스를 두 번 클릭하면 편집 모드로 전환되는 Label 형식 컨트롤입니다. BlockControl 및 Connector 개체가 사용합니다. |
ResizeHandle |
크기 조정 작업을 처리하는 BlockControl 주위의 작은 사각형입니다. |
ConnectorHandle |
BlockControl 개체 끌기 및 이 개체에 부착하는 작업을 처리하는 Connector 개체 끝점의 작은 사각형입니다. |
DiagramControl |
BlockControl 및 Connector가 상속을 받는 간단한 클래스입니다. "ID" 등의 일반적인 특성을 제공합니다. |
BlockControl |
텍스트 입력, 이동 및 크기 조정을 위한 주 다이어그램 작성 컨트롤로, Connector 개체와 연결됩니다. |
Connector |
BlockControl 개체를 선 및 화살표와 연결하는 컨트롤입니다. |
DrawingBoard |
BlockControl 및 Connector 개체를 배치하기 위한 화이트보드입니다. |
그림 2 및 그림 3에서는 DiagramLib의 클래스 계층 구조 및 이러한 클래스의 구성/연결을 확인할 수 있습니다. 또한 그림 4에서는 보다 뚜렷하게 다양한 클래스 간의 관계를 이해할 수 있습니다.
그림 2
그림 3
그림 4
Connector를 처리하는 방식에는 두 가지가 있습니다. 첫 번째는 다른 다이어그램 작성 컨트롤과 동일하게 취급하는 것입니다. 즉, Connector를 끌거나 크기를 조정할 수 있습니다. 그러나 보다 중요한 것은 Connector를 다른 화면 개체에 연결하지 않아도 된다는 것입니다. Visio에서는 이 방식을 사용합니다. 두 번째 방식을 사용하는 경우 Connector의 양 끝을 항상 무엇인가에 부착해야 합니다. 그러므로 Connector의 끝점이 아무 것에도 부착되지 않은 상태로 둘 수는 없습니다. Netron에서는 이 방식을 사용합니다. 개인적으로는 첫 번째 방식이 두 번째보다 직관적이라고 생각합니다. 그러므로 이 기사의 디자인은 Visio의 방식을 따릅니다.
다음 섹션에서는 클래스 및 여러 클래스를 함께 사용하는 방법에 대해 자세히 설명합니다.
끌기
Draggable 클래스는 UserControl에서 상속되지만 이 클래스에는 시각적인 구성 요소가 없습니다. 이 클래스는 MouseDown 이벤트 다음에 MouseMove 이벤트와 LocationChanged 이벤트가 뒤따르도록 정의되어 있는 마우스 끌기 동작을 처리합니다. 즉, 화면에서 클래스가 실제로 "끌기"되는 것이 아니라 사용자 정의 이벤트인 DraggableMouseMove, DraggableMouseStop 및 DraggableLocationChanged가 발생하는 것입니다. 이 Draggable 개체의 컨테이너가 실제 이동 또는 크기 조정을 수행합니다. 목록 1에는 Draggable 클래스의 세 가지 마우스 이벤트 처리기인 OnMouseDown, OnMouseUp 및 OnMouseMove가 있습니다.
목록 1. Draggable 클래스의 마우스 처리 루틴
public event MouseEventHandler DraggableMouseMove; public event MouseEventHandler DraggableMouseStop; protected virtual void OnDraggableMouseStop( MouseEventArgs e) { if (DraggableMouseStop != null) { // 대리자를 호출합니다. DraggableMouseStop(this, e); } } protected virtual void OnDraggableMouseMove( MouseEventArgs e) { if (DraggableMouseMove != null) { // 대리자를 호출합니다. DraggableMouseMove(this, e); } } protected virtual void OnMouseDown(Object o, MouseEventArgs e) { IsMouseDown = true; initX = e.X; initY = e.Y; } protected virtual void OnMouseUp(Object o, MouseEventArgs e) { if (IsMouseDown) { MouseEventArgs evt = new MouseEventArgs( e.Button, e.Clicks, e.X - initX, e.Y - initY, e.Delta); OnDraggableMouseStop(evt); } IsMouseDown = false; } protected virtual void OnMouseMove(object o, MouseEventArgs e) { if (IsMouseDown) { // Delta 값(이동한 거리)만 필요합니다. // e.X는 커서의 상대 위치입니다. MouseEventArgs evt = new MouseEventArgs( e.Button, e.Clicks, e.X - initX, e.Y - initY, e.Delta); OnDraggableMouseMove(evt); } }(참고: 프로그래머 코멘트는 샘플 프로그램 파일에는 영문으로 제공되며 기사에는 설명을 위해 번역문으로 제공됩니다.)
Draggable 클래스에서는 이벤트 처리기를 제공하는 반면 LabelBox 및 ResizeHandle 클래스에서는 시각적 요소 및 이벤트 처리기 간의 링크를 제공합니다. 예를 들어 ResizeHandle에는 마우스 이벤트를 캡처하고 Draggable 마우스 이벤트 처리기에 작업을 위임하는 Label 컨트롤(lblHandle)이 있습니다.
public ResizeHandle() { ... lblHandle.MouseDown += new MouseEventHandler( base.OnMouseDown); lblHandle.MouseUp += new MouseEventHandler( base.OnMouseUp); lblHandle.MouseMove += new MouseEventHandler( base.OnMouseMove); this.LocationChanged += new EventHandler( base.OnLocationChanged); ... }
앞서 Draggable 클래스에서 컨트롤의 실제 이동을 처리하지 않음을 언급했습니다. 이는 ResizeHandle 또는 LabelBox도 마찬가지입니다. 이러한 클래스는 DraggableMouseMove 이벤트를 상향 전파하여 부모 BlockControl 개체 또는 Connector 개체가 해당 작업을 처리하도록 합니다.
그림 5는 사용자가 ConnectorHandle을 끌면 클래스가 상호 작용하는 방법을 보여 주는 실제 시나리오 다이어그램입니다.
그림 5
부착 및 부착 해제 프로세스(1부): 부착
Connector의 ConnectorHandle을 BlockControl의 ResizeHandle과 아주 가까운 위치로 끌면 부착이 수행됩니다. 반대로 ConnectorHandle을 연결된 BlockControl에서 비교적 먼 위치로 끌면 연결 부착이 해제됩니다. 이 메커니즘을 설명하려면 BlockControl, Connector 및 ConnectorHandle의 구조를 자세히 살펴봐야 합니다.
BlockControl의 중앙에는 LabelBox가 있고 그 주위에는 8개의 ResizeHandle 개체가 있습니다. ResizeHandle 개체에는 그림 6과 같이 0에서 7까지의 인덱스가 지정되어 있습니다.
그림 6
나중에 살펴보겠지만 ConnectorHandle은 BlockControl에 부착되면 이 번호를 기록하기 때문에, ResizeHandle의 인덱스에는 중요한 의미가 있습니다.
Connector에는 각각 반대쪽 끝에 ConnectorHandle 개체가 두 개 있습니다(그림 7 참조).
그림 7
화면에서 ConnectorHandle을 끌면 모든 기존 BlockControl이 호출되어 해당 ResizeHandle 개체 중 ConnectorHandle에 부착될 수 있을 만큼 가까운 위치에 있는 개체가 있는지 확인합니다. BlockControl의 FindHandle 메서드(목록 2 참조)가 특정 지점으로부터 거리를 계산하여 해당 지점이 충분히 가까운 경우에는 ResizeHandle 인덱스를 반환합니다.
목록 2. BlockControl의 FindHandle 메서드
public int FindHandle(Point p) { const int GLUE_DISTANCE = 20; // 길이 20px int x = this.handles[HD_NW].Left; int y = this.handles[HD_NW].Top; int index = 0; double minDist = double.MaxValue; double dist = 0; for (int i = 0; i < NUM_HANDLE; i++) { ResizeHandle hd = this.handles[i]; dist = Math.Sqrt(Math.Pow(hd.Left - p.X, 2) + Math.Pow(hd.Top - p.Y, 2)); if (dist < minDist) { index = i; minDist = dist; } } if (minDist < GLUE_DISTANCE) { return index; } else { return -1; } }
그림 8에는 BlockControl이 하나만 있지만 실제로 프로그램에서는 화면의 모든 BlockControl 개체를 호출합니다.
그림 8
BlockControl 개체 중 하나에서 유효한 ResizeHandle 인덱스를 반환하는 경우, 이는 Connector가 해당 BlockControl에 즉시 연결되어야 함을 의미합니다. 그러면 ConnectorHandle 위치가 갑자기 변경되어 맞추기 및 붙이기 형식으로 이동합니다.
ConnectorHandle은 ResizeHandle에서 상속되며 BlockControl에 부착되거나 BlockControl에서 부착이 해제되는 기능이 추가됩니다. ConnectorHandle은 BlockControl에 부착되면 BlockControl 이동을 따르며, 그로 인해 부모 Connector 개체의 크기와 모양을 변경합니다. ConnectorHandle은 부착된 BlockControl 및 ResizeHandle을 기억함으로써 이 작업을 수행합니다.
public class ConnectorHandle: ResizeHandle { private BlockControl connectedBlock; private int blockHandleIndex; ...
부착 및 부착 해제 프로세스(2부): 부착 후 이동
Connector 및 BlockControl이 서로 부착된 후에 BlockControl의 해당 부분을 끌거나 크기를 조정하면 Connector의 크기도 자동으로 조정됩니다. 이는 ResizeHandle에서 LocationChanged 이벤트를 부모 BlockControl로 전송하면 부모 BlockControl은 연결된 모든 Connector가 해당 ConnectorHandle의 위치를 변경하도록 지시하기 때문입니다(그림 9 참조).
그림 9
부착 및 부착 해제 프로세스(3부): 부착 해제
연결된 BlockControl에서 ConnectorHandle을 끌거나 LabelBox 개체를 끌어 중앙에서 다른 위치로 이동하면 부착 해제가 수행됩니다. 이 작업은 끈 위치가 임계값에 도달할 때까지 마우스 이동을 무시함으로써 수행됩니다.
// ConnectorHandle 클래스 protected override void OnMouseMove(Object o, MouseEventArgs e) { if (this.connectedBlock == null) { base.OnMouseMove(o, e); } else { if (ShouldBreakBinding(e)) { connectedBlock.RemoveConnectorHandle( this, blockHandleIndex); connectedBlock = null; base.OnMouseMove(o, e); } } } private Boolean ShouldBreakBinding(MouseEventArgs e) { return (Math.Sqrt(Math.Pow(e.X,2) + Math.Pow(e.Y, 2)) > 20); }
Connector의 LabelBox 개체를 끌어서 이동해도 역시 양 끝의 연결이 끊어집니다.
// Connector 클래스 private void Label_DraggableMouseMove(Object o, MouseEventArgs e) { // 핸들 중 하나가 Block 컨트롤에 연결되어 있는 경우 // 저항이 필요합니다. if (handles[0].ConnectedBlockControl != null || handles[1].ConnectedBlockControl != null) { double dist = Math.Sqrt(Math.Pow(e.X, 2) + Math.Pow(e.Y, 2)); if (dist <= 20) return; } ... }
그림
이 섹션에서는 화면에서 컨트롤을 그리는 방법에 대해 자세히 다룹니다. 일부 컨트롤은 Windows Label로 구성되는 ResizeHandle 같은 Windows 기본 요소만으로 이루어져 있습니다. 그리고 GDI+ 라이브러리를 사용하여 화면 업데이트를 수행하는 컨트롤도 있습니다. 여기서는 GDI+ 관련 컨트롤에 대해 주로 설명합니다.
LabelBox 클래스 LabelBox는 개체의 텍스트 설명을 보여 주는 직사각형 상자입니다(그림 4 참조). BlockControl 및 Connector 클래스는 모두 이 클래스를 사용합니다. 이 클래스를 두 번 클릭하면 텍스트 상자가 표시되어 사용자가 내용을 편집할 수 있습니다. 이름에서 짐작되듯이 이 클래스에는 Label 컨트롤이 포함되어 있다고 생각될 수 있습니다. 그리고 이전 버전까지는 실제로 그랬습니다. 그러나 Label 컨트롤은 새로 고침 속도가 매우 느립니다. 화면에서 함께 움직이는 Label 컨트롤이 5개 이상인 경우에는 속도가 너무 느려서 이상하게 보이기도 합니다. 그러므로 이 LabelBox 버전의 경우에는 GDI+ 호출을 통해 직사각형과 텍스트를 다시 그리도록 했습니다.
// LabelBox 클래스 private void LabelBox_Paint(Object o, PaintEventArgs e) { €| e.Graphics.FillRectangle(backgroundBrush, 0, 0, this.Width, this.Height); e.Graphics.DrawRectangle(textPen, 0, 0, this.Width - 1, this.Height - 1); e.Graphics.DrawString(text, txtEdit.Font, textBrush, 2, 2); }
Connector DrawingBoard 개체는 화면을 새로 고쳐야 할 때 Connector 개체의 Paint 메서드를 호출합니다. ConnectionStyleEnum 속성이 이 클래스에서 설정할 수 있는 스타일을 지정합니다. 이 값과 각 값을 설정했을 때의 효과가
그림 10에 나와 있습니다.
그림 10
LeftRight 및 UpDown 스타일의 경우 정적 다차원 배열을 사용하여 화살표 회전 각도를 결정합니다.
// Connector 클래스 private static int[,] degrees = new int[4,2]; static Connector() { // 화살촉을 그리기 위한 각도 매트릭스를 초기화합니다. // updown, 핸들 1 위에 핸들 0 degrees[Convert.ToInt16(ConnectorStyleEnum.UpDown), Convert.ToInt16(true)] = 0; // updown, 핸들 1 아래 핸들 0 degrees[Convert.ToInt16(ConnectorStyleEnum.UpDown), Convert.ToInt16(false)] = 180; // leftright, 핸들 1 왼쪽에 핸들 0 degrees[Convert.ToInt16(ConnectorStyleEnum.LeftRight), Convert.ToInt16(true)] = -90; // leftright, 핸들 1 오른쪽에 핸들 0 degrees[Convert.ToInt16(ConnectorStyleEnum.LeftRight), Convert.ToInt16(false)] = 90; }
정적 생성자가 배열 각도를 초기화합니다. 클래스의 정적 생성자는 응용 프로그램 수명 동안 단 한 번만 호출됩니다. 생성자가 트리거되는 때는 클래스의 첫 번째 인스턴스를 만든 때나 정적 멤버가 처음으로 참조되는 때입니다. 화살표의 회전 각도를 확인하려면 치수를 채우면 됩니다.
// Connector 클래스 private void DrawHandlesArrows(Graphics g) { double h0Deg = 0; double h1Deg = 0; switch (connStyle) { case ConnectorStyleEnum.LeftRight: h0Deg = degrees[(int) connStyle, Convert.ToInt16(handles[0].Left < handles[1].Left)]; break; €| }
화살촉 좌표가 일련의 점으로 기록됩니다. 그림 11 및 그림 12에는 이러한 점이 각각 좌표축 및 화면 좌표로 표시되어 있습니다.
그림 11
그림 12
선 끝에 올바른 각도로 화살촉을 그리려면 세 가지 변환 작업을 수행해야 합니다.
-
변환 - 화살촉이 중앙(0, 0)에서 약간 벗어나도록 이동합니다. 이 작업은 화살촉이 ConnectorHandle과 같은 영역에 있지 않도록 하기 위해 수행합니다.
-
회전 - 선 경사도를 기준으로 정확한 각도를 계산합니다.
-
변환 - 화살촉을 선 끝으로 이동합니다.
다음 코드 세그먼트에서는 화살촉 생성 및 그림 13에 나와 있는 3단계 변환 과정을 설명합니다.
double grad = (float) (handles[1].Top - handles[0].Top) / (float) (handles[1].Left - handles[0].Left); double deg = Math.Atan(grad) * (180 / Math.PI); €| Point[] p = new Point[] { new Point(0, 0), new Point(5, 7), new Point(0, 5), new Point(-5, 7), new Point(0, 0)}; path.AddLines(p); Matrix m = new Matrix(); m.Translate(x, y); // 직선 끝 m.Rotate((float) deg); m.Translate(0, 5); path.Transform(m);
그림 13
결론
이 기사에서 소개한 8가지 클래스에는 기본적인 기능만이 포함되지만, 이를 기반으로 하여 보다 수준 높은 프레임워크를 만들 수 있습니다. 즉시 사용할 수 있는 라이브러리가 필요한 경우에는 http://netron.sourceforge.net/ (영문)의 공개 소스 Netron 그래프 라이브러리를 확인해 보십시오.
다운로드 단추를 클릭하면 코드(506LEW.ZIP)를 다운로드할 수 있습니다.
Visual Studio .NET Developer 및 Pinnacle Publishing에 대한 자세한 내용을 보려면 해당 웹 사이트인 http://www.pinpub.com/(영문 사이트)을 방문하십시오.
참고: 이 사이트는 Microsoft Corporation 웹 사이트가 아니므로 Microsoft는 그 내용에 책임을 지지 않습니다.
이 기사는 Visual Studio .NET Developer 2005년 6월호를 바탕으로 다시 쓰여진 것입니다. Copyright 2005, by Pinnacle Publishing, Inc., unless otherwise noted. All rights are reserved. Visual Studio .NET Developer는 Pinnacle Publishing, Inc에서 독립적으로 제작한 발행물입니다. 이 기사의 어떤 부분도 Pinnacle Publishing, Inc.의 사전 동의 없이는 어떤 형식으로도(중요한 기사나 논평에서 간략하게 인용하는 경우는 제외함) 사용하거나 복제할 수 없습니다. Pinnacle Publishing, Inc.에 연락하려면 1-800-788-1900으로 전화 주십시오.
출처 : http://www.xdotnet.com/xdotnet/board/BoardRead.aspx?F_Code=11&B_Code=1010&B_Index=1280&B_Page=0