C# :: Lecture & TIPs

[C#] Windows Forms 에서 화면 너비에 따라 PictureBox 작업 영역을 반응형으로 재배치하기

Surface 기기의 화면 회전부터 혼합 DPI, 다중 모니터와 RDP 원격 데스크톱 (Remote Desktop Connection) 재연결까지 대응하기

Windows Forms 기반의 데스크톱 앱을 개발할 때에는 일반적으로 가로 방향의 모니터를 기준으로 화면을 구성하게 됩니다.

다음과 같이 원본 이미지를 표시하는 Source, 필터 처리 결과를 보여주는 Result, 이전 처리 내역을 확인하는 Smoothing History 영역을 가로로 나란히 배치했습니다.

 

 

Form 의 상단에는 가로로 늘어선 고정 설정 영역이 있고, 창 크기에 따라 달라지는 것은 그 아래의 이미지 작업 영역 (Source / Result / Smoothing History) 뿐입니다. 가로 공간이 충분한 화면에서는 세 가지 영역을 나란히 배치해 원본과 결과를 한 화면에서 직접 비교하고, 이전에 적용했던 설정도 동시에 확인할 수 있습니다.

그러나 Surface 와 같은 태블릿 PC 를 세로 방향으로 회전하거나 앱을 폭이 좁은 모니터로 옮기면 배치를 그대로 유지하기 어렵습니다. 세 가지 영역의 너비만 계속 줄이면 Source (PictureBox) 와 Result (PictureBox) 는 이미지를 비교하기에 지나치게 좁아지고, Smoothing History (ListView) 도 내용을 확인할 수 없는 크기로 압축되는 문제가 존재하는데요,

앞서 언급한 문제에 대해 본 게시글에서는 Microsoft 사 (社) 가 Windows 앱 디자인 가이드 (Fluent Design 철학) 에서 공식적으로 권장하는 반응형 디자인 기술 중 위치 변경 (Reposition) 기법을 Winform 환경에 어떻게 적용했는지를 기술하고 소개합니다. Microsoft 는 본 기법에 대해 UI 요소의 위치와 배치를 바꿔 창 크기를 효율적으로 활용하는 방식으로 설명합니다. 작은 창에서는 요소를 세로로 쌓고, 창이 넓어지면 늘어난 너비를 활용하도록 요소를 다시 배치하는 것이 핵심이며, 여백과 크기를 조절하는 “크기 조정” 기법과 구분됩니다. 요소의 크기를 줄이는 대신 배치 자체를 바꾸므로, 좁은 화면에서도 각 컨트롤의 가독성과 조작성을 유지할 수 있습니다.

따라서 각 컨트롤의 크기를 무조건 줄이는 대신, 사용할 수 있는 가로 공간이 부족해지는 순간 작업 영역의 배치 방향을 가로에서 세로로 전환하도록 구성했습니다. 구현 결과, 창 크기나 화면 방향이 변경되거나 전환되더라도 Source, Result, Smoothing History 각각의 영역에 대해 텍스트나 레이아웃이 깨지거나 짤리지 않는 충분한 크기를 유지합니다.

 

반응형 디자인 기술 : 위치 변경
(UI 요소의 위치와 배치를 변경하여 창 크기를 최대한 활용할 수 있으며, 창의 구성 요소가 세로로 스택되어 있습니다. 앱을 큰 창으로 변환하면 내부 각각의 요소가 보다 넓은 창 너비를 활용할 수 있습니다.)
https://learn.microsoft.com/ko-kr/windows/apps/design/layout/responsive-design

 

 

다만 해당 기능은 모니터가 가로 방향인지 세로 방향인지를 단순히 구분하는 방식으로 구현하지 않고, 실제 구현에서는 다음 두 가지 단계를 서로 분리했습니다.

  1. 현재 모니터의 작업 영역과 DPI를 확인하여 창 크기와 최소 크기를 보정합니다.
  2. 보정된 Form 의 실제 ClientSize.Width 를 기준으로 하단 이미지 작업 영역의 배치를 결정합니다.

두 가지 조건을 분리한 것이 이번 구현의 핵심입니다.

 

전제 조건

본 글의 DPI 처리 (DeviceDpi, LogicalToDeviceUnits(), WM_DPICHANGED) 는 다음 환경을 전제로 합니다.

대상 Framework 버전 : .NET Framework 4.7 이상
DPI 인식 모드 : PerMonitorV2 (Windows 10 1703 이상), app.manifest 에 선언
폼 설정 : AutoScaleMode = Dpi (아래 Designer 코드 참고)

PerMonitorV2 로 선언되어 있지 않으면 창을 다른 배율의 모니터로 이동시켜도 WM_DPICHANGED 메시지가 전달되지 않으므로, 본 글의 동작을 그대로 재현할 수 없습니다.

 

핵심 구성

이번 반응형 레이아웃의 흐름을 먼저 정리하면 다음과 같습니다 :

  • 레이아웃의 적층 여부는 모니터 방향이 아니라 Form 의 ClientSize.Width 로 판단합니다.
  • 넓은 상태에서는 Source (PictureBox), Result (PictureBox) 컨트롤과 Smoothing History (ListView) 컨트롤을 3 열로 표시합니다.
  • 좁은 상태에서는 세 가지 영역을 1 열 9 행 구조로 재배치합니다.
  • PictureBox 만 이동시키지 않고 제목과 하단 정보 영역까지 하나의 기능 단위로 함께 이동합니다.
  • 최소화 상태에서는 잘못된 크기를 기준으로 레이아웃을 판단하지 않습니다.
  • 사용자 창 크기 조절 중에는 적층에서 가로 레이아웃으로 돌아가는 전환을 보류합니다.
  • 화면 회전, 모니터 이동, DPI 변경과 RDP 재연결은 하나의 안정화 경로에서 처리합니다.
  • PictureBox 의 크기가 바뀌어도 이전 표시 배율과 화면 중심의 이미지 좌표를 가능한 범위에서 복원합니다.
  • Linked View 가 사용 중이면 크기 변경 후 마지막 master 를 기준으로 두 뷰를 다시 정렬합니다

 

모니터 방향이 아니라 Form 의 실제 너비를 기준으로 판단하기

처음에는 단순히 현재 모니터가 세로 방향인지 여부에 대해 확인하는 방법을 생각할 수 있습니다.

 

bool portrait =
    workingArea.Height > workingArea.Width;

 

하지만 이 조건만으로는 실제 UI 를 정상적으로 표시할 수 있는지 판단하기 어렵습니다.

가로 방향 모니터에서도 사용자가 창의 너비를 줄일 수 있고, 작업 표시줄이 화면의 왼쪽이나 오른쪽에 배치되어 있으면 같은 해상도에서도 사용할 수 있는 공간이 줄어듭니다.

반대로 세로 방향 모니터라도 해상도와 화면 배율에 따라 상당한 가로 공간을 제공할 수도 있습니다.

그래서 실제 구현에서는 모니터의 방향이 아니라, 현재 Form 의 클라이언트 너비가 가로 레이아웃을 표시할 수 있는지를 기준으로 판단했습니다.

 

private const int ImageAreaStackThresholdWidth = 1340;

bool stacked =
    ClientSize.Width <
    LogicalToDeviceUnits(
        ImageAreaStackThresholdWidth);

if (_stackedImageLayout == stacked)
    return;

 

1340 픽셀은 96 DPI 기준의 논리적 클라이언트 너비입니다. LogicalToDeviceUnits() 함수를 통해 현재 Form 의 DPI 에 맞게 변환되므로 100%, 150% 또는 200% 환경에서도 같은 논리적 기준을 사용할 수 있습니다.

가로 레이아웃이 온전히 들어가기 위해 필요한 Form 크기는 별도의 상수로 정의해두었습니다.

 

private const int WideLayoutRequiredWidth = 1356;
private const int WideLayoutRequiredHeight = 790;

 

1356 픽셀과 1340 픽셀이 다른 값으로 측정된 까닭

위 두 상수는 같은 가로형 UI 를 서로 다른 기준으로 측정한 값입니다.

  • WideLayoutRequiredWidth × Height (1356 × 790): Form 전체 외곽 크기
  • ImageAreaStackThresholdWidth (1340): Form 의 클라이언트 너비 (제목 표시줄과 테두리를 뺀 영역)

두 가지 값은 Designer 파일의 코드를 기반으로 도출된 값입니다.

this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
this.ClientSize = new System.Drawing.Size(1340, 751);
this.MinimumSize = new System.Drawing.Size(1356, 790);

 

구분너비높이
Designer ClientSize (클라이언트)1340751
비클라이언트 영역 (테두리, 제목 표시줄)1639
Designer MinimumSize (외곽)1356790

 

즉 1356 × 790은 각 컨트롤의 최소 너비를 런타임에 더해서 얻은 값이 아니라, 가로형 UI 가 잘림 없이 표시되던 Designer 크기를 96 DPI 논리값으로 고정한 설계 기준값입니다. (WideLayoutRequiredWidth 1356 - window chrome ≈ 1340).

같은 기준에서 나온 두 가지 값이라도 사용되는 목적이 다릅니다.

 

상수비교 대상사용 목적
1356 × 790Form 외곽 크기 (Form.Size, 작업 영역)① UpdateMinimumSizeForScreen : 모니터가 가로형을 수용할 수 있는지 판단하고, 수용 가능하면 MinimumSize로 사용

② OnShown: 시작 모니터의 수용 능력을 _lastCanHostWideLayout에 기록

③ EnsureLayoutFitsScreen : 적층 상태에서 가로 수용 모니터로 돌아왔을 때 창 확장

④ ApplyResponsiveImageLayout : 적층 → 가로 복귀 시 snap-back
1340ClientSize.WidthApplyResponsiveImageLayout : 3 열 / 적층 판정

 

UpdateMinimumSizeForScreen()에서 1356 픽셀이 사용되는 부분은 아래와 같습니다.

 

int wideMinW = (int)(WideLayoutRequiredWidth * scaleFactor);
int wideMinH = (int)(WideLayoutRequiredHeight * scaleFactor);

 

1356 픽셀은 Form의 절대 최소 크기 그 자체를 뜻하는 것은 아닙니다. 가로형 레이아웃을 수용할 수 있는 디스플레이에서만 이 값이 MinimumSize로 지정되고, 그 외의 디스플레이에서는 앞서 설명했던 절대 최소 크기가 적용됩니다

한 가지 한계점도 있습니다. 창 테두리와 제목 표시줄의 두께는 Windows 버전, DPI 배율, 창 스타일, 접근성 설정에 따라 달라질 수 있습니다. 코드는 이를 런타임마다 계산하지 않고 96 DPI 기준값에 배율을 곱합니다. 결과적으로 외곽 크기는 어디까지나 근사치이고, 최종 배치는 ClientSize.Width와 1340 픽셀에 배율을 적용한 값을 비교하여 판단합니다. 테두리 차이로 외곽 크기가 조금 달라지더라도 내부 요소는 실제 클라이언트 영역을 기준으로 정렬되므로, 오차가 발생하더라도 화면이 잘리는 문제가 바로 발생하지는 않습니다.

기존 임계값은 1100 픽셀이었지만, 이 경우 1100 픽셀부터 약 1340 픽셀 사이에서 가로 레이아웃이 선택되면서도 실제 세 가지 영역은 정상적으로 들어가지 않는 구간이 생겼습니다.

실제로 Surface Pro 9 기기에서 테스트 해 본 결과 150 % 배율의 세로 방향으로 사용했을 때 해당 구간에 들어가며, 세로 화면인데도 잘린 가로 레이아웃이 선택되는 문제가 발생했습니다.

따라서 전환 임계값은 단순히 보기 좋은 숫자로 정하는 것이 아니라, 가로 레이아웃을 실제로 잘리지 않게 표시할 수 있는 최소 클라이언트 너비와 맞춰야 합니다.

 

최초 상태와 회전 전환을 구분하기 위한 Tri-state 플래그

현재 Form 에는 bool? 형식의 플래그가 두 개 있습니다. 하나는 현재 배치 모드를, 다른 하나는 화면 회전 전환을 구분하는 데 쓰입니다.

① 현재 배치 모드: _stackedImageLayout

현재 레이아웃 상태를 저장하는 _stackedImageLayout은 일반 bool이 아니라 bool? 형식입니다.

 

private bool? _stackedImageLayout;

 

각 값은 다음 상태를 나타냅니다.

  • null : 최초 레이아웃 판단이 이루어지지 않은 시점
  • true : 1 열 세로 적층 레이아웃
  • false : 3 열 가로 레이아웃

 

위 구분은 창 크기를 다시 키우는 snap-back 조건에서 쓰입니다. snap-back 은 실제 적층 상태에서 가로형으로 되돌아 전환 될 때 (previous == true) 만 실행되고, 최초의 null → wide 절차에서는 실행되지 않습니다.

 

if (!stacked && previous == true && WindowState == FormWindowState.Normal)

 

더불어, 현재 레이아웃과 원하는 레이아웃이 같으면 즉시 반환합니다.

 

if (_stackedImageLayout == stacked)
    return;

 

Resize 이벤트가 반복해서 발생하더라도 배치 상태가 바뀌지 않았다면 행과 열을 다시 구성하지 않습니다.

 

② 회전 전환 감지: _lastCanHostWideLayout

private bool? _lastCanHostWideLayout;

 

위의 _lastCanHostWideLayout 값은 이전 보정 과정에서 모니터가 가로 레이아웃을 지원하는지 여부를 기억합니다. 이로 인하여, 단순히 지금 화면이 세로라는 사실 뿐만 아니라, ‘가로에서 세로로 금방 회전했다’ 는 사실을 식별할 수 있으며, 세로 모드용 초기 창 너비가 화면 회전 시 딱 한번만 적용되므로, 나중에 사용자가 수동으로 창 크기를 조절할 때도 충돌이 일어나지 않습니다.

 

bool justRotatedToPortrait = _lastCanHostWideLayout == true && !canHostWide;

 

위의 예시에서 == true 로 비교하는데, 시작 시점의 초기값이 중요합니다. 값이 null이면 첫 회전이 전환으로 인식되지 않아, 세로용 초기 너비 적용이 처음 한 번 누락되는 케이스가 발생합니다. 따라서, OnShown에서 시작 모니터의 수용이 가능한지에 대한 여부를 미리 확인 후 기록해 둡니다.

 

var seedWa = GetCurrentWorkingArea();
double seedScale = GetLiveDpi() / 96.0;
_lastCanHostWideLayout =
    seedWa.Width >= (int)(WideLayoutRequiredWidth * seedScale) &&
    seedWa.Height >= (int)(WideLayoutRequiredHeight * seedScale);

 

시작 모니터가 가로 레이아웃을 수용할 수 있으면 true 로 시작하므로, 처음 하는 회전도 정상적인 가로 → 세로 전환으로 감지됩니다. 판정 조건은 UpdateMinimumSizeForScreen()의 canHostWideLayout 과 동일합니다.

 

항목_stackedImageLayout_lastCanHostWideLayout
기억하는 것현재 배치 모드직전 보정 절차에서 모니터가 가로 레이아웃을 수용할 수 있었는지
값의 의미null 판단 전 / true 1 열 적층 / false 3 열null 기록 전 / true 수용 가능 / false 수용 불가
최초 설정첫 ApplyResponsiveImageLayout() 실행 시OnShown 에서 시작 모니터의 수용 능력으로 미리 기록
갱신 위치ApplyResponsiveImageLayout() : 전환 전에 새 값을 커밋하고, 예외가 발생하면 이전 값으로 롤백EnsureLayoutFitsScreen() : 회전 판정 직후 canHostWide 값으로 갱신
읽는 곳같은 모드의 중복 처리 반환, 드래그 중 가로 승격 보류, snap-back 조건 (previous == true), 적층 상태에서 가로 수용 모니터로 돌아왔을 때 창 확장, 상태바 제목 라벨 표시 여부justRotatedToPortrait 판정
null 인 경우비교 결과가 달라서 첫 절차는 반드시 실행되고, snap-back 과 창 확장 조건 (== true) 은 만족하지 않음== true 비교에 실패해서 첫 회전이 전환으로 인식되지 않음
값으로 구분하는 것최초 null → wide와 실제 stacked → wide 복귀, 같은 모드의 중복 처리“세로 상태인 상태” 과 “가로에서 세로로 방금 전환 한 상태”

 

최소화 상태는 레이아웃 판단에서 제외하기

최소화 상태는 레이아웃 판단에서 제외합니다. 최소화된 창은 ClientSize 로 0 × 0 에 가까운 비정상적인 (degenerate) 값을 보고합니다. 즉, 해당 값으로 모드를 결정한다면 최소화할 때마다 적층 배치로 전환되었다가 복원할 때 다시 가로 배치로 돌아가는 불필요한 재배치가 추가로 발생합니다. 이를 방지하기 위해 최소화 상태에서는 판단을 건너뛰고 직전의 배치 모드를 유지하도록 설계하도록 합니다.

 

if (WindowState == FormWindowState.Minimized)
    return;

 

PictureBox 컨트롤러에서도 동일한 원칙을 사용하여 구현합니다.

 

if (_pb.ClientSize.Width <= 0 ||
    _pb.ClientSize.Height <= 0)
{
    return;
}

 

Form 은 최소화 중 반응형 레이아웃을 변경하지 않고, PictureBox 컨트롤러는 0 × 0 크기에서 뷰포트 좌표와 배율을 다시 계산하지 않습니다.

두 가지 처리가 함께 동작하므로 창을 최소화했다가 복원하더라도 레이아웃 상태와 사용자가 최소화 직전의 이미지 영역 (ROI) 은 보던 직전 그대로의 상태를 유지할 수 있었습니다.

 

가로로 넓은 화면 (가로형 화면) 에서는 5 열 3 행 구조 사용하기

화면이 충분히 넓은 경우 TableLayoutPanel은 5 열 3 행 구조를 사용합니다.

실제 콘텐츠 영역은 Source, Result와 History 에 해당하는 세 개의 열이며, 나머지 두 개의 열은 각 영역 사이의 간격입니다.

tlp.ColumnCount = 5;
tlp.RowCount = 3;

tlp.ColumnStyles.Clear();
tlp.ColumnStyles.Add(
    new ColumnStyle(SizeType.Percent, 38.95681F));

tlp.ColumnStyles.Add(
    new ColumnStyle(SizeType.Percent, 0.3170842F));

tlp.ColumnStyles.Add(
    new ColumnStyle(SizeType.Percent, 38.95682F));

tlp.ColumnStyles.Add(
    new ColumnStyle(SizeType.Percent, 0.3170842F));

tlp.ColumnStyles.Add(
    new ColumnStyle(SizeType.Percent, 21.4522F));

 

Source (PictureBox) 와 Result (PictureBox) 에는 각각 약 39% 를 배정하고, History 에는 약 21% 를 배정했습니다.

두 이미지를 비교하는 것이 앱의 핵심 기능이므로 Source 와 Result 에는 동일한 너비를 제공하고, Smoothing History 에는 목록을 확인할 수 있는 범위에서 상대적으로 작은 너비를 할당했습니다.

행은 다음 구조입니다.

 

tlp.RowStyles.Clear();

tlp.RowStyles.Add(new RowStyle());
tlp.RowStyles.Add(
    new RowStyle(SizeType.Percent, 100F));
tlp.RowStyles.Add(new RowStyle());

 

  • 첫 번째 행: 제목
  • 두 번째 행: PictureBox 또는 ListView
  • 세 번째 행: 하단 상태

중요한 점은 PictureBox만 이동하지 않는다는 것입니다.

Source 영역은 다음 세 개 컨트롤로 이루어집니다.

  • lblSource
  • pictureBoxSource
  • tableLayoutPanel5

Result 영역은 다음과 같습니다.

  • lblResult
  • pictureBoxResult
  • tableLayoutPanel6

History 영역 역시 세 개의 요소로 구성됩니다.

  • lblHistory
  • lvHistory
  • tableLayoutPanel7

따라서 각 기능 영역의 제목, 콘텐츠 및 하단 영역을 항상 함께 이동합니다.

 

SetImageCell(lblSource, 0, 0);
SetImageCell(pictureBoxSource, 0, 1);
SetImageCell(tableLayoutPanel5, 0, 2);

SetImageCell(lblResult, 2, 0);
SetImageCell(pictureBoxResult, 2, 1);
SetImageCell(tableLayoutPanel6, 2, 2);

SetImageCell(lblHistory, 4, 0);
SetImageCell(lvHistory, 4, 1);
SetImageCell(tableLayoutPanel7, 4, 2);

 

이는 개별 컨트롤을 재배치한다기보다는 사용자가 하나의 기능으로 인식하는 작업 단위를 재배치하는 방식입니다.

 

좁은 화면 (세로형 화면) 에서는 1 열 9 행으로 적층하기

Form 의 클라이언트 너비가 기준값보다 작아지면 동일한 9 개 컨트롤을 첫 번째 열에 세로로 배치합니다.

 

SetImageCell(lblSource, 0, 0);
SetImageCell(pictureBoxSource, 0, 1);
SetImageCell(tableLayoutPanel5, 0, 2);

SetImageCell(lblResult, 0, 3);
SetImageCell(pictureBoxResult, 0, 4);
SetImageCell(tableLayoutPanel6, 0, 5);

SetImageCell(lblHistory, 0, 6);
SetImageCell(lvHistory, 0, 7);
SetImageCell(tableLayoutPanel7, 0, 8);

 

다음 테이블을 1 열 9 행 구조로 변경합니다.

 

tlp.RowStyles.Clear();

for (int i = 0; i < 3; i++)
{
    tlp.RowStyles.Add(new RowStyle());

    tlp.RowStyles.Add(
        new RowStyle(
            SizeType.Percent,
            100F / 3F));

    tlp.RowStyles.Add(new RowStyle());
}

 

제목과 하단 영역은 필요한 높이만 사용하고, Source (PictureBox), Result (PictureBox) 컨트롤, 그리고 History (ListView) 컨트롤이 들어가는 콘텐츠 행은 남은 높이를 각각 1 / 3 씩 사용합니다.

따라서 가로 공간이 부족할 때 각 영역의 면적을 계속 좁혀나가는 대신, 화면의 전체 너비를 활용할 수 있습니다.

 

축소와 확장 시 처리 순서 구분하기

가로형에서 세로형으로 바뀔 때와 반대로, 세로형에서 가로형으로 되돌아갈 때는 처리 순서가 다릅니다.

세로 적층으로 전환할 때는 먼저 모든 컨트롤을 첫 번째 열로 이동한 다음 그리드의 열 개수를 줄이는 방식으로 구현하였습니다.

 

// 3 열에서 1 열 UI 로 전환
SetImageCell(...);

tlp.RowCount = 9;
tlp.ColumnCount = 1;

 

반대로 가로형으로 돌아갈 때는 먼저 필요한 행과 열을 만든 후 컨트롤을 이동하도록 구현합니다.

 

// 1 열에서 3 열 UI 로 전환
tlp.ColumnCount = 5;
tlp.RowCount = 3;

SetImageCell(...);

 

일반적인 TableLayoutPanel 동작을 고려하면 존재하지 않는 셀에 잔여 컨트롤이 남거나, 아직 생성되지 않은 셀로 컨트롤을 옮기는 중간 상태를 피하기 위한 순서로, “먼저 이동한 후 축소”, “먼저 확장한 후 이동” 하도록 구현하시는 편을 권장합니다.

레이아웃 변경 전체는 SuspendLayout()과 ResumeLayout(true) 사이에서 실행됩니다.

 

tlp.SuspendLayout();

try
{
    // 레이아웃 구조 및 셀 위치 변경
}
finally
{
    tlp.ResumeLayout(true);
}

 

레이아웃이 중단된 상태에서 Form 크기를 변경하면 두 개의 미완성 레이아웃 절차가 섞이면서 Label 컨트롤이 제대로 렌더링 되지 않거나 창 일부가 손상되는 문제가 발생하게 되므로, 적층에서 가로형으로 돌아오면서 Form 크기를 다시 확대하는 작업은 반드시 ResumeLayout() 이후에 수행해야 합니다.

 

재진입을 막고 실패한 상태는 커밋 (Commit) 상태로 변경하지 않기

가로 레이아웃으로 돌아올 때는 필요한 경우 Form 크기까지 변경합니다.

Form 크기가 바뀌면 OnResize()가 다시 호출되므로, 레이아웃 전환 중 같은 메서드가 중첩 실행될 수 있습니다.

이를 방지하기 위해 재진입 방지 장치 (Guard) 를 사용합니다.

 

private bool _responsiveLayoutRunning;

if (_responsiveLayoutRunning)
    return;

 

레이아웃 모드를 변경하기 전에는 기존 값을 저장합니다.

 

bool? previous = _stackedImageLayout;

_stackedImageLayout = stacked;
_responsiveLayoutRunning = true;

 

전환 중 예외가 발생하면 다음 예시와 같이 이전 값을 복원합니다.

 

catch
{
    _stackedImageLayout = previous;
    throw;
}
finally
{
    _responsiveLayoutRunning = false;
}

 

롤백 과정이 없으면 레이아웃이 절반만 구성된 상태에서도 내부 플래그는 새로운 모드가 이미 적용된 것으로 기록될 수 있기에,

이후 호출 과정에서는 상태 값이 동일하다는 이유로 즉시 반환하므로, 손상된 배치가 그대로 남게 됩니다.

현재 구현은 레이아웃 전환이 성공한 경우에만 새로운 상태가 실질적으로 유지되도록 구성되어 있습니다.

 

사용자가 창 크기를 조절하는 동안에는 승격을 보류하기

사용자가 창 테두리를 마우스나 터치로 끌 때 Resize 이벤트가 연속으로 발생하면서 세로로 적층되어 있던 창이 임계값보다 일시적으로 넓어지면, 가로형 레이아웃으로 전환되면서 창 크기가 다시 확대되는 스냅 백 (Snap-back) 동작이 추가로 발생할 수 있습니다.

사용자가 창 테두리를 잡고 있는 동안 창이 자동으로 커지게 된다면 조작 의도와 충돌합니다.

따라서 크기 조절 중에는 적층 레이아웃에서 가로 레이아웃으로 돌아가는 전환만 보류하도록 합니다.

 

if (_inUserSizeMove &&
    !stacked &&
    _stackedImageLayout == true)
{
    return;
}

 

두 가지 방향의 전환 정책은 비대칭입니다.

  • 가로형에서 적층형으로 내려가는 전환은 즉시 수행합니다.
  • 적층형에서 가로형으로 올라가는 전환은 사용자가 크기 조절을 끝낼 때까지 기다리도록 합니다.

너비가 부족한 상태에서 가로 레이아웃을 계속 유지하면 콘텐츠가 잘리므로 강등은 즉시 수행하지만, 충분한 공간이 다시 생겼다는 사실은 사용자의 조작이 끝난 후 확정해도 문제가 없기 때문입니다.

사용자가 창 크기 조절을 시작하고 끝내는 시점은 Win32 API 의 WM_ENTERSIZEMOVE 와 WM_EXITSIZEMOVE 메시지로 확인할 수 있습니다.

 

private const int WM_ENTERSIZEMOVE = 0x0231;
private const int WM_EXITSIZEMOVE = 0x0232;

 

크기 조절이 끝나면 메시지 반복문 한 번을 수행한 후 최종 크기를 기준으로 다시 판단합니다.

 

BeginInvoke((Action)(() =>
{
    if (IsDisposed)
        return;

    ApplyResponsiveImageLayout();
    UpdateProgressBarLayout();
}));

 

이와 같이 구현하면 창 크기 조절이 완료된 시점에 레이아웃을 갱신하여, 크기 조절 과정에서 발생하는 불필요한 레이아웃 연산과 깜빡임을 줄일 수 있습니다.

 

화면 회전은 단순한 Resize 가 아닙니다

Surface Pro 기기를 비롯한 태블릿을 회전하면 창 자체는 같은 위치에 남아 있고, 창 아래의 디스플레이 크기와 방향만 바뀔 수 있습니다. 따라서 기기 환경에 따라 일반적인 Resize 나 LocationChanged 만으로 화면 회전을 안정적으로 감지하기 어렵습니다.

현재 구현에서는 여러 신호를 하나의 화면 맞춤 경로로 통일화하고 있습니다.

  • 일반 창 크기 변경: OnResize
  • 최대화 상태에서 일반 창으로 복원: OnResize에서 상태 비교
  • 다른 모니터로 이동: OnLocationChanged
  • 해상도 또는 화면 방향 변경: WM_DISPLAYCHANGE
  • DPI 변경: WM_DPICHANGED, OnDpiChanged
  • RDP 재연결 및 잠금 해제: WM_WTSSESSION_CHANGE
  • 사용자 창 크기 조절 종료: WM_EXITSIZEMOVE

화면 회전 한 번에도 여러 메시지가 연속해서 발생할 수 있습니다.

모든 알림에서 즉시 창 크기와 레이아웃을 다시 계산하면 같은 작업이 반복되고 중간 상태가 화면에 나타날 수 있으므로 120 ms debounce 를 적용했습니다.

 

private const int EnsureLayoutDebounceMs = 120;

 

새로운 요청이 들어올 때마다 타이머를 다시 시작합니다.

 

_ensureLayoutDebounce.Stop();
_ensureLayoutDebounce.Start();

 

마지막 알림 이후 120ms 동안 추가 요청이 없을 때 EnsureLayoutFitsScreen() 을 한 번 실행합니다.

여기서 중요한 점은 타이머를 예약할 당시의 DPI 나 화면 크기를 따로 저장해두지 않는다는 점입니다.

실행 시점에 실제 화면의 DPI 와 현재 모니터의 작업 영역을 다시 확인하므로, debounce 는 정확성을 보완하는 수단이 아니라, 중복 레이아웃 절차를 줄이는 성능 최적화에 가깝습니다.

 

1 단계 : 현재 모니터에 맞게 창 크기 보정하기

하단 이미지 작업 영역의 적층 여부는 ClientSize.Width로 판단하지만, 이전에 현재 앱의 창이 위치한 모니터에 맞도록 Form 의 최소 크기와 경계를 보정합니다.

 

double scaleFactor =
    GetLiveDpi() / 96.0;

int wideMinW =
    (int)(WideLayoutRequiredWidth * scaleFactor);

int wideMinH =
    (int)(WideLayoutRequiredHeight * scaleFactor);

var wa = GetCurrentWorkingArea();

bool canHostWideLayout =
    wa.Width >= wideMinW &&
    wa.Height >= wideMinH;

 

현재 모니터가 DPI 보정된 가로 레이아웃의 필수 크기를 수용할 수 있다면 가로형 최소 크기를 사용하도록 구현한 실제 코드입니다.

여기에서 사용되는 1356 × 790 사이즈는 앞에서 설명한 가로형 Form 의 외곽 크기입니다.

 

Size newMin = canHostWideLayout
    ? new Size(wideMinW, wideMinH)
    : new Size(
        (int)(FormAbsoluteMinWidth * scaleFactor),
        (int)(FormAbsoluteMinHeight * scaleFactor));

 

현재 코드에서 정의한 절대 최소 크기는 다음과 같습니다.

 

private const int FormAbsoluteMinWidth = 960 - 48;
private const int FormAbsoluteMinHeight = 1356;

 

가로 레이아웃을 수용할 수 없는 모니터, 즉 세로 모니터나 작은 화면에서만 이 값이 MinimumSize 가 됩니다. 코드 상 절대 최소 크기는 논리 좌표(96 DPI) 기준 912 × 1356 픽셀입니다. 가로 레이아웃을 수용할 수 없는 모니터, 즉 세로 모니터나 작은 화면에서만 절대 최소 크기가 MinimumSize 가 됩니다. 너비 912 픽셀은 세로 화면의 기준 너비 960 픽셀에서 48 픽셀을 뺀 값인데, Windows 111 에서는 작업 표시줄을 화면 좌측, 우측에도 선택하여 배치가 가능하여, 작업 영역 너비가 논리적으로 대략 48 픽셀만큼 줄어들기에 작업 표시줄이 측면에 있더라도 Form 이 정상적으로 표시되도록 기준 너비에서 미리 차감했습니다. 48 은 근사값이며 모든 환경에 적용 가능한 값은 아닙니다. 작업 표시줄의 폭은 운영체제 버전과 사용자의 조절에 따라 달라질 수 있습니다.

너비 하한은 상단 고정 설정 영역과 상태 표시줄의 텍스트가 잘리지 않고 모두 표시되도록 보호하는 값이며, 그래서 MinimumSize로 유지합니다. 상단 설정 영역은 수평 배치된 고정 구조라 반응형으로 재배치되지 않으므로, Form 너비가 이보다 좁아지면 UI 가 잘립니다. 반면 하단 이미지 작업 영역은 너비 하한 기준 이하의 크기에서도 세로 적층 레이아웃으로 전환되어 계속 사용할 수 있으므로 하한을 낮게 설정할 수 있었습니다.

높이 1356 픽셀은 세로 모니터에서 창이 가질 수 있는 최소 높이이며, 회전 직후 높이 하한으로 쓰입니다.

내부 UI 가 잘리거나 겹치는 상황을 허용하기보다는, 창의 일부가 화면 밖으로 넘치더라도 UI 무결성을 유지하는 쪽을 선택하여, 이 하한은 모니터 작업 영역이 더 작아져도 완화하지 않습니다. 일반 창 상태에서는 창 일부가 작업 영역 밖으로 넘치는 것을 허용하되, 좌상단을 작업 영역 안에 맞춰 제목 표시줄에는 항상 접근할 수 있게 했습니다. 이후에 창을 이동할지, 창의 크기를 조정할지, 최대화할지는 사용자의 판단과 결정에 맡겼습니다. 내부 UI가 잘리거나 겹치는 것보다 창이 넘치는 쪽을 택한 것입니다.

 

상수값 (논리 px)기준역할근거
WideLayoutRequiredWidth × Height1356 × 790Form 외곽가로 수용 판단, snap-back 크기, 가로를 수용하는 모니터의 MinimumSizeDesigner MinimumSize
FormAbsoluteMinWidth × Height912 × 1356Form 외곽가로를 수용하지 못하는 모니터의 MinimumSize (상단 설정 영역 및 상태 표시줄 보호, 회전 직후 높이 하한)너비 960 - 48 (근사 여유)
ImageAreaStackThresholdWidth1340클라이언트 너비하단 이미지 영역의 3열 / 적층 판정Designer ClientSize.Width

  

  1. 업데이트 : KB5124008
    정식 배포일 : 2026년 9월 8일
    Windows 11 24H2 : OS 빌드 26100.9445 또는 그 이상의 Windows 11 버전
    Windows 11 25H2 : OS 빌드 26200.9445 또는 그 이상의 Windows 11 버전 ↩︎

 

2 단계 : Screen 캐시를 우회하여 작업 영역을 직접 가져오기

이와 같이 구현한 결과, .NET Framework 의 Screen 정보가 태블릿 화면 회전 직후 이전 작업 영역을 반환하는 문제가 발생하여 수정이 필요했습니다.

같은 모니터가 회전하면 모니터의 식별자는 그대로이면서 너비와 높이가 바뀝니다. 하지만 캐시된 값을 사용하면 회전 전 가로 작업 영역을 계속 기준으로 사용할 수 있습니다. 따라서 현재 창이 표시된 모니터를 Win32 API 로 확인합니다.

 

IntPtr hMon = MonitorFromWindow(
    Handle,
    MONITOR_DEFAULTTONEAREST);

 

이후 GetMonitorInfo() 를 이용해 작업 영역 (Working Area) 정보를 다시 가져옵니다.

 

var mi = new MONITORINFO
{
    cbSize = Marshal.SizeOf(
        typeof(MONITORINFO))
};

if (GetMonitorInfo(hMon, ref mi))
{
    return Rectangle.FromLTRB(
        mi.rcWork.Left,
        mi.rcWork.Top,
        mi.rcWork.Right,
        mi.rcWork.Bottom);
}

 

네이티브 호출을 사용할 수 없거나 실패한 경우에만 Screen.FromControl(this).WorkingArea로 돌아갑니다.

따라서 화면 회전이나 디스플레이 변경 직후의 핵심 계산은 Win32 를 통해 조회한 실제 작업 영역을 우선적으로 사용하도록 구현했습니다.

 

DeviceDpi 대신 실제 DPI 를 사용하는 이유

모니터 사이에서 창을 이동하면 OnLocationChanged() 가 WM_DPICHANGED 보다 먼저 발생할 수 있습니다. 해당 시점에는 창이 이미 새로운 모니터에 있지만 DeviceDpi 는 이전 모니터 값을 가지고 있을 가능성이 존재합니다. 새로운 모니터의 작업 영역과 이전 모니터의 DPI를 조합하면 잘못된 최소 크기가 계산됩니다.
위의 경우를 방지하기 위해 GetDpiForWindow() 로 현재 창의 활성화된 모니터의 DPI 를 직접 조회합니다.

 

private int GetLiveDpi()
{
    try
    {
        uint dpi = GetDpiForWindow(Handle);

        if (dpi != 0)
            return (int)dpi;
    }
    catch (Exception ex)
        when (ex is EntryPointNotFoundException ||
             ex is DllNotFoundException)
    {
    }

    return DeviceDpi;
}

DeviceDpi 는 WM_DPICHANGED 를 처리해야 갱신되는데, 이 메시지가 항상 적합한 타이밍에 수신되지는 않습니다.

배율이 다른 모니터를 확장 모드로 사용하는 경우에 대표적입니다.

핸들과 DPI 스케일링은 주 모니터 기준으로 만들어지므로, 창이 배율이 다른 확장 모니터에서 표시되는 경우, (예 : 주 200 %, 확장 100 %) 보정 메시지가 수신되지 않아 Form 전체가 엉뚱한 배율로 남을 수 있습니다.

해상도와 Windows 배율을 바꾼 직후 화면을 회전하면 메시지가 합쳐져 사라지거나 중간 상태의 지오메트리로 전달되어, 레이아웃은 세로로 바뀌는데 컨트롤은 이전 배율로 남아 대부분 잘려 보이는 증상도 발생합니다.

이에, 최초 표시 시점 (OnShown) 과 매 보정 절의 맨 처음에 실제 DPI와 DeviceDpi 를 비교하도록 했습니다. 두 가지 값이 동일하면 아무 처리도 하지 않고, 다른 경우에 한해서만 운영체제단에서 보냈어야 할 WM_DPICHANGED 를 직접 보냅니다. 위 코드의 결과로 Windows Form 이 최종 DPI 로 한 번만 깔끔하게 배율을 적용하여 표시합니다.

 

if (!_sessionDetached && !IsSessionDisconnectedNow())
    ReplayDpiChangeIfStale();

 

GetDpiForWindow 는 Windows 10 1607 이상에서 사용할 수 있는 API 이고, 사용할 수 없는 환경에서는 DeviceDpi 로 대체합니다.

해상도와 Windows 배율을 바꾼 직후 화면을 회전하면 WM_DPICHANGED 가 한 번도 처리되지 않는 경우가 있습니다. 이때 DeviceDpi 는 이전 배율에 머물러, 레이아웃은 세로로 바뀌는데 컨트롤은 2배 스케일로 남아 대부분 잘려 보입니다.

따라서 보정 절차 (EnsureLayoutFitsScreen) 의 맨 처음에 실제 DPI와 DeviceDpi 를 맞춰 동기화합니다. 두 값이 동일하면 어떠한 처리도 하지 않고, 다른 경우에 한해서만 WM_DPICHANGED 를 직접 보냅니다.

 

모니터 환경에 따라 창 경계 보정하기

EnsureLayoutFitsScreen() 은 현재 작업 영역과 DPI 를 다시 읽은 다음 상황에 따라 창의 크기와 위치를 조정합니다.

if (WindowState != FormWindowState.Normal)
{
    // Maximized windows are re-fitted by the OS; just re-evaluate.
    ApplyResponsiveImageLayout();
    return;
}

 

적층 상태에서 넓은 모니터로 돌아온 경우

현재 레이아웃이 적층 상태이고 모니터의 작업 영역 너비가 가로형 최소 너비를 수용할 수 있다면, 가로 레이아웃의 필수 크기까지 창을 확장합니다.

 

if (_stackedImageLayout == true &&
    wa.Width >= minW)
{
    int w = Math.Max(Width, minW);
    int h = Math.Min(
        Math.Max(Height, minH),
        wa.Height);

    // 작업 영역 안으로 위치 보정
}

  

최대화된 창은 운영체제가 맞추므로 창 경계는 건드리지 않고 배치만 다시 판단합니다.

창을 사용자가 조금씩 넓힐 때까지 적층 레이아웃을 유지하면 하단 정보 영역이 중간 크기에서 잘릴 수 있으므로, 조건을 만족하는 시점에 가로 레이아웃을 수용할 수 있는 크기로 전환 및 변경합니다.

 

가로 환경에서 세로 환경으로 회전한 경우

이전에는 가로형 작업 영역을 수용할 수 있었지만, 현재는 수용할 수 없다면 실제 가로에서 세로로의 전환으로 판단합니다.

 

bool justRotatedToPortrait =
    _lastCanHostWideLayout == true &&
    !canHostWide;

 

else if (justRotatedToPortrait)
{
    int w = MinimumSize.Width;
    int h = Math.Max(Math.Min(Height, wa.Height), MinimumSize.Height);
    int x = Math.Max(wa.Left, wa.Left + (wa.Width - w) / 2);
    int y = Math.Max(wa.Top, Math.Min(Top, wa.Bottom - h));
    Bounds = new Rectangle(x, y, w, h);
}

 

위의 절차에서는 MinimumSize 가 이미 세로 모니터의 값으로 갱신된 상태입니다. 너비 (weight) 는 가로 모드의 너비를 유지하지 않고 세로용 최소 너비로 맞춘 뒤 가로 중앙에 배치합니다. 높이 (height) 는 해당 시점의 창 높이를 유지하되 작업 영역을 넘지 않도록 제한하고, 창의 레이아웃이 깨지지 않도록 유지한 최소 높이는 보장하도록 하였습니다.

 

해당 시점의 높이결과
최소 높이보다 작음최소 높이 이상으로 올림
최소 높이와 작업 영역 높이 사이현재 높이 유지
작업 영역 높이보다 큼작업 영역 높이로 제한 (최소 높이는 유지)

 

이 보정은 실제 가로 → 세로 전환 시점에 한 번만 실행됩니다. _lastCanHostWideLayout의 기록 방식은 앞의 tri-state 섹션에서 설명한 바 있습니다.

해당 동작은 실제 화면 전환 시 한 번만 수행되므로, 이후 사용자가 직접 창의 너비를 변경하는 작업과 충돌하지 않습니다.

 

창이 작업 영역을 벗어난 경우

이외에 창이 현재 작업 영역보다 크거나 화면 밖으로 나가 있다면 창의 크기를 다시 제한합니다.

다만 운영체제가 다시 최소 크기로 늘리면 위치 계산에 사용한 크기와 실제 크기에 차이가 있을 수 있으므로 MinimumSize보다 작은 값은 요청하지 않습니다.

보정을 마친 후, ApplyResponsiveImageLayout()을 호출하여 최종 ClientSize.Width를 기준으로 하단 영역의 배치를 결정합니다.

 

적층에서 가로형으로 돌아올 때의 snap-back

ApplyResponsiveImageLayout()에도 실제 적층에서 가로형으로 전환될 때 창 크기를 보정하는 경로가 있습니다.

다음 세 조건을 모두 만족해야 합니다.

  1. 새 레이아웃이 가로형이고
  2. 이전 레이아웃이 실제 적층 상태였으며
  3. 창 상태가 Normal 인 경우

 

if (!stacked &&
    previous == true &&
    WindowState == FormWindowState.Normal)
{
    int minW =
        LogicalToDeviceUnits(
            WideLayoutRequiredWidth);

    int minH =
        LogicalToDeviceUnits(
            WideLayoutRequiredHeight);

    if (Width < minW || Height < minH)
    {
        Size = new Size(
            Math.Max(Width, minW),
            Math.Max(Height, minH));

        // 작업 영역 안으로 Bounds 보정
    }
}

 

최대화된 창은 운영체제가 크기를 관리하므로 수동으로 다시 확대하지 않도록 하였으며, 최초 null → wide 적용 시에도 실행되지 않습니다. 실제 stacked → wide 전환 시 현재 크기가 부족한 경우에만 위의 코드가 동작합니다.

또한 가드 (Guard) 에 의해 Resize 처리가 차단되므로, 원래 Resize 이벤트에서 수행해야 했던 진행률 표시줄 위치 갱신을 직접 다시 호출합니다.

 

UpdateProgressBarLayout();

 

레이아웃 전환 후 상태 표시줄과 History 다시 구성하기

하단 작업 영역만 재배치된 시점에서 작업이 완료되는 것은 아닙니다.

세로 적층 상태에서는 Form 의 가로 공간이 줄어들기 때문에 상태 표시줄에 각각의 정적 Label 의 텍스트와 값을 모두 표시하기 어려울 수 있기에, 다음과 같은 정적 Label 을 숨기고 값만 유지합니다.

아래의 정적 Label 은 값이 텍스트인 관계로 값만 유지하더라도 어떠한 정보인지 유추가 가능합니다.

 

정적 Label 의 Text값
Color Depth8-Bit
16-Bit
Calibrated TypeRectangular Average,
Binomial Average,
Binomial Weighted Median,
Gaussian Weighted Median,
Gaussian Filter,
Savitzky Golay Filter
Boundary HandlingAdaptive Mask
Symmetric
Replicate
Zero Padding

 

UpdateStatusStripLabelVisibility(stacked);

 

다시 가로형으로 돌아오면 대응하는 값 Label 이 실제로 표시 중인 상태인 경우에만 제목을 복원합니다.

History 목록 역시 폭이 크게 바뀌므로 기존의 행 구성을 그대로 사용할 수 없습니다.

 

마지막으로 셀 이동과 같은 프레임에서 발생한 창 크기 변경으로 잔상이 남거나 Label 이 그려지지 않는 상황을 막기 위해 전체 영역을 다시 렌더링합니다.

 

tlp.Invalidate(true);
statusStrip1.Invalidate(true);
Update();

 

위 구현으로 레이아웃 전환이 논리적으로 완료되었더라도 하위 창에 잔상이 남거나, 새롭게 표시된 Label의 렌더링이 누락되는 문제를 보완합니다.

 

RDP (Remote Desktop Connection) 원격 데스크톱 재연결과 임시 디스플레이 대응하기

세션이 분리되거나 잠기면 데스크톱이 임시 더미 디스플레이(보통 96 DPI)로 전환됩니다. WM_DPICHANGED 가 Form 에 도달하면 컨트롤이 한 번 축소됐다가 재연결 시 다시 확대되는데, 이 왕복 과정에서 반올림 오차가 누적되어 레이아웃이 영구적으로 손상되므로 (컨트롤이 잘리거나 늘어남). 이를 방지하기 위해 해당 메시지를 차단 (가드) 합니다.

 

if (m.Msg == WM_DPICHANGED)
{
    if (IsSessionDisconnectedNow())
    {
        _sessionDetached = true;
        _dpiChangeSuppressed = true;
        _ensureLayoutDebounce?.Stop();
        m.Result = IntPtr.Zero;
        return;
    }
    _sessionDetached = false;
    _dpiChangeSuppressed = false;
    // ...
}

 

판단 기준은 플래그가 아니라 실시간 세션 상태입니다. WTS 이벤트 수신이 WM_DPICHANGED 보다 늦게 도착할 수 있어, 분리 시점에 플래그가 아직 false이거나 재연결 시점에 아직 true일 수 있기 때문입니다.

재연결 시점에는 두 가지 작업을 수행합니다.

첫째, 창이 이미 최종 DPI에 도달해 있어 OS가 메시지를 다시 보내지 않으므로, 차단했던 RDP 변경 사항을 ReplaySuppressedDpiChange()로 다시 반영합니다.

둘째, 레이아웃 보정을 예약합니다.

 

ReplaySuppressedDpiChange();
ScheduleEnsureLayoutFitsScreen();

 

재연결 알림 직후의 첫 번째 (약 120ms) 절차에서는 잠시 임시 디스플레이 값이 보일 수 있습니다. RDP (Remote Desktop Connection) 가 WTS_REMOTE_CONNECT 신호를 전달받은 뒤 잠시 후에 세션 해상도와 DPI 를 다시 조정하기 때문입니다. 따라서 ReconnectRefitDelayMs = 700 설정 뒤에 동일한 두 호출을 한 번 더 실행합니다. 두 번째 절차 역시 디바운스 (debounce) 를 거쳐 실제 값으로 계산되므로, 이미 동일한 값으로 변경되어 맞춰져 있는 상태라면 별 다른 변화가 없습니다.

  

PictureBox 크기가 바뀌어도 보던 영역 (ROI) 유지하기

반응형 레이아웃이 전환되면 Source (PictureBox) 와 Result (PictureBox) 의 크기와 종횡비가 크게 달라집니다.

단순히 PictureBoxSizeMode.Zoom 을 사용하는 경우 새 크기에 맞춰 이미지 전체가 다시 정렬되면서, 사용자가 확대하여 확인하던 위치를 잃을 수 있습니다.

현재 구현에서는 PictureBoxViewportController가 PictureBox의 기본 이미지 그리기를 사용하지 않고 직접 렌더링합니다.

 

_pb.SizeMode = PictureBoxSizeMode.Normal;
_pb.Paint += OnPaint;
_pb.Resize += OnResize;

 

뷰포트 상태는 크게 다음 값으로 구성됩니다.

  • _zoom: fit-to-window를 1.0으로 한 상대 줌
  • _offset: 현재 보이는 이미지 영역의 이미지 좌표 기반 오프셋
  • _unclampedOffset: 클램프 이전의 ROI 기준 오프셋
  • fitScale × _zoom: 실제 화면에 적용되는 절대 표시 배율

따라서 PictureBox 크기가 변경될 때 _zoom 값을 그대로 유지하는 것이 아니라, 이전 표시 배율과 화면 중심의 이미지 좌표를 새 크기에 맞게 복원합니다.

 

변경 전 표시 배율과 중심 좌표 저장하기

유효한 이전 크기가 있다면 변경 전의 표시 배율을 계산합니다.

float oldFitScale = Math.Min(
    (float)_lastClientSize.Width / img.Width,
    (float)_lastClientSize.Height / img.Height);

float oldDisplayScale =
    oldFitScale * _zoom;

 

화면 중심에 있던 이미지 좌표도 저장합니다.

일반적인 리사이즈에서는 _unclampedOffset을 사용합니다.

 

PointF roiBasis =
    _rotateAnimTimer == null
        ? _unclampedOffset
        : _offset;

oldCenterImg = new PointF(
    img.Width / 2f + roiBasis.X,
    img.Height / 2f + roiBasis.Y);

 

일반 리사이즈에서 _offset이 아닌 _unclampedOffset을 사용하는 이유는 반응형 전환 도중 PictureBox가 여러 중간 크기를 거칠 수 있기 때문입니다.

중간 단계에서 이미지가 한 축에 완전히 들어오면 ClampOffset()은 해당 축의 오프셋을 중앙으로 되돌립니다.

이 클램프된 값을 계속 다음 리사이즈의 기준으로 사용하면, 사용자가 보고 있던 관심 영역이 전환 과정에서 조금씩 이미지 중앙으로 이동할 수 있습니다.

따라서 실제 ROI 기준이 되는 클램프 이전 오프셋을 보존하고, 최종 PictureBox 크기에서 다시 제한합니다.

회전 애니메이션 중에는 별도의 보간 상태가 진행되고 있으므로 현재 표시 중인 _offset을 사용합니다.

 

새로운 PictureBox 크기에 맞춰 표시 배율 복원하기

새로운 PictureBox 크기에서 fitScale 을 다시 계산합니다.

 

float newFitScale = GetFitScale();

 

기존 표시 배율을 유지할 수 있도록 내부 _zoom 배율을 다시 계산합니다.

 

if (newFitScale > 0f &&
    oldDisplayScale > 0f)
{
    _zoom =
        oldDisplayScale /
        newFitScale;
}

 

현재 허용 범위로 제한합니다.

 

float newMax = MaxZoom;

if (_zoom > newMax)
    _zoom = newMax;

if (_zoom < MinZoom)
    _zoom = MinZoom;

 

따라서 유지되는 것은 내부 _zoom 숫자 자체가 아니라 fitScale × _zoom으로 계산되는 표시 배율입니다.

다만 새로운 PictureBox 가 충분히 커져 기존 표시 배율이 fit-to-window보다 작아지는 경우에는 MinZoom 제한에 따라 전체 맞춤 상태로 돌아갑니다.

반대로 새로운 PictureBox 가 작아지면 가능한 범위 안에서 같은 표시 배율을 유지하기 때문에 이미지 전체가 아니라 기존 중심 주변만 보일 수 있습니다.

따라서 최대한 가능한 범위에서 사용자가 보던 배율과 중심 위치, ROI 를 유지하게 됩니다.

 

이전 이미지 중심을 새 뷰포트의 중심으로 복원하기

이전 화면 중심에 있던 이미지 좌표가 새로운 PictureBox에서도 중앙에 오도록 오프셋을 다시 계산합니다.

 

float newOffsetX =
    oldCenterImg.X -
    img.Width / 2f;

float newOffsetY =
    oldCenterImg.Y -
    img.Height / 2f;

 

일반적인 리사이즈 경로에서는 진행 중인 smooth zoom, bounce 및 pan inertia를 정리한 후 새로운 크기에서 오프셋을 제한하고 PictureBox를 다시 그립니다.

유효한 크기로 복원이 끝나면 LayoutRestored 이벤트를 발생시킵니다.

중요한 점은 이 경로가 프로그램에 의한 레이아웃 복원이므로 ViewportChanged를 발생시키지 않는다는 것입니다.

따라서 단순한 화면 회전이나 창 크기 변경이 사용자 편집으로 기록되거나 프로젝트 dirty 상태를 만들지 않습니다.

 

Linked View는 마지막 조작 뷰를 기준으로 다시 정렬하기

Source와 Result가 Linked View로 연결된 경우 각 PictureBox는 서로 다른 크기와 종횡비를 가질 수 있습니다.

두 컨트롤은 각자의 OnResize()에서 독립적으로 표시 배율과 오프셋을 복원하고 제한하므로, 복원 직후 아주 작은 차이가 생길 수 있습니다.

ViewportLinkCoordinator 는 두 컨트롤의 LayoutRestored 이벤트를 구독합니다.

 

view.LayoutRestored += layoutHandler;

 

복원할 master는 다음 우선순위로 결정됩니다.

  1. 최소화 또는 일시적 레이아웃 붕괴로 인해 재정렬이 보류된 master
  2. 마지막으로 사용자가 조작한 _lastMaster

 

var master = _pendingRealignMaster;
_pendingRealignMaster = null;

if (master == null)
    master = _lastMaster;

if (master == null)
    return;

 

master 에 진행 중이던 줌, 바운스 또는 관성 애니메이션이 있었다면 최종 상태로 정착시킨 후

 

master.SettleAnimationsToFinalState();

 

follower 에 다시 전파합니다.

 

Propagate(master);

 

이미 두 화면이 정렬되어 있다면 FollowFrom() 의 epsilon 비교에 의해 실질적인 변경이나 다시 그리기 없이 종료됩니다.

반대로 최소화된 상태에서 follower가 유효한 크기를 갖지 못해 동기화가 보류되었다면, 제한된 재시도 후 LayoutRestored 시점에 다시 정렬합니다.

따라서 화면 회전이나 최소화 및 복원 이후에도 사용자가 마지막으로 조작했던 뷰를 기준으로 Linked View 의 표시 배율과 중심 ROI 를 다시 일치시킬 수 있습니다

 

구현 결과

이 구현은 다음 두 가지 문제를 서로 분리해서 처리합니다.

창과 모니터 환경의 문제
  • 현재 모니터의 작업 영역을 최신 값 기준으로 읽음
  • 현재 창의 DPI 를 최신 값 기준으로 읽음
  • Form 의 MinimumSize 를 현재 화면에 맞게 변경
  • 화면 회전과 모니터 이동 후 Form 경계 보정
  • RDP 분리 중 임시 디스플레이 값 무시
  • 재연결 후 최종 DPI 및 작업 영역으로 복구
하단 작업 영역의 배치 문제
  • 현재 ClientSize.Width 가 기준보다 작은지 판단
  • 충분히 넓으면 3 열 구조 사용
  • 좁으면 1 열 9 행으로 적층
  • 제목, 콘텐츠 및 하단 영역을 하나의 단위로 이동
  • 전환 중 재진입과 중간 상태 커밋 방지
  • PictureBox 표시 배율과 중심 ROI 복원
  • Linked View의 master-follower 관계 재정렬

즉, 모니터 방향이 레이아웃을 직접 결정하는 것이 아닙니다.

모니터와 DPI 는 먼저 창이 어떤 크기와 위치를 가질 수 있는지 결정하고, 최종적으로 확보된 Form 의 클라이언트 너비가 하단 작업 영역을 가로 또는 세로로 배치할지 결정합니다.

 

정리

  • 배치 판단 : ClientSize.Width < LogicalToDeviceUnits(1340)
  • 넓은 배치 : 5 열 3 행, Source 약 39 %, Result 약 39 %, History 약 21 %
  • 좁은 배치 : 1 열 9 행, 세 콘텐츠 행이 남은 높이를 3 등분
  • 전환 순서 : 축소는 셀 이동 후 구조 축소, 확장은 구조 확장 후 셀 이동
  • 상태 관리: nullable 레이아웃 상태, 재진입 가드 및 실패 시 롤백
  • 사용자 크기 변경 : 가로형 승격은 드래그 종료까지 보류하고 적층 전환은 즉시 수행
  • 화면 환경 감지 : Resize, LocationChanged, WM_DISPLAYCHANGE, WM_DPICHANGED와 세션 변경
  • 모니터 정보 : MonitorFromWindow, GetMonitorInfo
  • DPI 정보 : GetDpiForWindow, 필요 시 DeviceDpi 폴백
  • 중복 방지: 120 ms debounce
  • RDP 재연결 : 즉시 보정과 700 ms 이후 두 번째 안정화 절
  • 뷰포트 복원 : 화면 중심 이미지 좌표와 절대 표시 배율 복원 후 허용 범위로 제한
  • Linked View : LayoutRestored에서 마지막 master 의 최종 상태를 follower 에 재전파

 

마무리하며…

일반적인 Windows Forms 앱에서는 창의 너비가 줄어들면 컨트롤의 크기도 함께 줄이거나, 일부 영역을 숨기는 방식으로 대응하기 쉽습니다. 하지만 Source (PictureBox) 와 Result (PictureBox) 및 Smoothing History (ListView) 컨트롤들은 단순한 메뉴가 아니라 동일한 이미지 처리 과정에 포함되는 핵심 작업 영역입니다.

이 중 하나를 별도의 메뉴 안에 숨기면 화면 공간은 확보할 수 있지만, 원본과 결과를 비교하거나 이전 처리 내역을 확인할 때마다 추가적인 화면 전환이 필요합니다. 이에, 핵심 작업 영역을 숨기는 대신, 사용할 수 있는 공간에 따라 정보의 배치 방향을 변경하도록 구현했습니다.

가로 공간이 충분할 때는 세 가지 영역을 나란히 표시하여 원본과 결과를 직접 비교할 수 있도록 하고, 폭이 좁아지면 각 영역을 위, 아래로 재배치하여 PictureBox 가 지나치게 압축되지 않도록 했습니다.

추가로, 화면 회전, 혼합된 DPI 사용 시 두 대의 모니터 간 창 이동, 최대화와 복원, RDP 재연결 및 최소화 상태까지 하나의 창 크기 보정 경로로 연결했습니다. 반응형 전환 과정에서도 이미지를 다시 생성하거나 뷰포트 (Viewport) 인 PictureBox 두 개를 초기화하지 않고, 변경 전 화면 중심의 이미지 좌표와 표시 배율을 새로운 PictureBox 크기에 맞춰 복원합니다.

Linked View를 사용 중인 경우에는 마지막으로 사용자가 조작했던 뷰를 기준으로 두 개의 PictureBox 를 다시 정렬합니다.

결과적으로 모니터가 가로인지 세로인지를 직접 묻지 않습니다. 대신 다음 두 가지 질문을 순서대로 확인합니다.

 

현재 모니터 환경에서 창을 어느 크기로, 어느 위치에 배치하여 표시할 수 있는가?

그리고,

그 결과 확보된 클라이언트 너비로 3 열 작업 영역을 온전히 표시할 수 있는가?

Windows Forms 프로젝트가 기본적으로 반응형 UI 를 위한 프레임워크는 아니더라도, 모니터 환경을 처리하는 계층과 콘텐츠 배치를 처리하는 계층을 분리하고, 뷰포트 (Viewport) 상태를 레이아웃과 독립적으로 관리한다면 다양한 화면 방향과 입력 환경에 대응하는 작업 공간을 구성할 수 있습니다.

고맙습니다.

 

Leave a Reply

Discover more from Dream big, Achieve more.

Subscribe now to keep reading and get access to the full archive.

Continue reading