суббота, 15 июня 2013 г.

VCL Form and Frame Scale Fix

В продолжение развитии темы базовой формы и фреймы. Хорошая новость: я решил добавить поддержку Delphi 7, которая оказалась довольно живучей. Ещё я сделал разделение пакета на два: DesignTime only (пакет с визардами) и RunTime only (пакет с модулем BaseForms). Плюс я хочу поделиться решением одной насущной проблемы…

Многие Delphi-программисты знают, что в VCL происходит некорректное масштабирование форм. Само масштабирование применяется в случае, когда текущее логическое разрешение экрана (значение Screen.PixelsPerInch) не совпадает с тем, при котором разрабатывалась форма в дизайнере (значение PixelsPerInch, сохранённое в DFM-файле). Некорректность заключается в том, что в некоторых случаях масштабирование не применяется к размеру самой формы, но применяется ко всем дочерним контролам. Также не масштабируются констрейнты формы, что приводит к ещё более некрасивым результатам – сначала форма масштабируется, а потом её размер ограничивается старыми констрейнтами.

А ещё VCL не масштабирует фреймы. Т.е. если фрейму создать в Run-Time вручную, а после этого встроить в форму, то фрейма останется неотмасштабированной.

Для наглядности приведу картинки.

Так форма выглядит без масштабирования (ppi = 96) в классической теме оформления (слева) и  Aero (справа):

imageimage

А так уже с масштабированием (ppi = 120):

imageimage

Тут видно, что внешний размер формы не изменился, хотя сами контролы отмасштабировались. А ещё на картинке справа уменьшился размер клиентской области.

Теперь я укажу у формы свойство BorderStyle = bsSingle, это неявным образом заставит VCL сохранить свойства ClientWidth и ClientHeight в DFM-файл формы. Вот что получилось:

imageimage

Похоже, что в этом случае масштабирование прошло корректно. А теперь у формы указываю констрейнты, получаем такую картинку:

imageimage

Всё отмасштабировалось, но потом размер формы сбросился до констрейнтов, которые не отмасштабировались.

Ну и на последок я вернул форме стиль границы и сбросил якоря для всех контролов в значения по умолчанию ([akLeft, akTop]), получилось так:

imageimage

На картинках показано приложение, собранное в Delphi 7. В Delphi 2010 проблемы масштабирования проявляются реже, но всё же проявляются. Например, достаточно выставить свойство AutoScroll = True, и получим поведение масштабирования как на второй картинке.

Есть ещё одна проблема: VCL не масштабирует фреймы, созданные в Run-Time. Вот простой пример:

procedure TForm1.Button1Click(Sender: TObject);
var
  Form: TForm;
  Frame: TFrame2;
begin
  Form := TForm.Create(Self);
  Frame := TFrame2.Create(Form);
  Form.ClientHeight := Frame.Height;
  Form.ClientWidth := Frame.Width;
  Frame.Align := alClient;
  Frame.Parent := Form;
  Form.Show;
end;

По клику на кнопке получим форму с неотмасштабированной фреймой. Это воспроизводится и в Delphi XE2 (в более поздних пока не проверял).

Фиксим масштабирование форм

Я решил эту проблему в несколько этапов.

  1. Необходимо во время дизайна формы принудительно сохранять значения ClientWidth и ClientHeight.
  2. Необходимо отключить масштабирование на уровне VCL.
  3. Необходимо самостоятельно выполнить масштабирование.

Первое решается довольно просто:

..
  private
    procedure WriteClientHeight(Writer: TWriter);
    procedure WriteClientWidth(Writer: TWriter);
  protected
    procedure DefineProperties(Filer: TFiler); override;
..
procedure TBaseForm.WriteClientHeight(Writer: TWriter);
begin
  Writer.WriteInteger(ClientHeight);
end;

procedure TBaseForm.WriteClientWidth(Writer: TWriter);
begin
  Writer.WriteInteger(ClientWidth);
end;

procedure TBaseForm.DefineProperties(Filer: TFiler);
  function NeedWriteClientSize: Boolean;
  begin
    //Result := Scaled and not IsClientSizeStored
    // IsClientSizeStored = not IsFormSizeStored
    // IsFormSizeStored = AutoScroll or (HorzScrollBar.Range <> 0) or (VertScrollBar.Range <> 0)
    Result := Scaled and (AutoScroll or (HorzScrollBar.Range <> 0) or (VertScrollBar.Range <> 0));
  end;
begin
  inherited DefineProperties(Filer);

  // ClientHeight и ClientWidth сохраняются не всегда, а вместо этого сохраняются внешние размеры формы.
  // Это не совсем правильно, т.к. масштабировать необходимо именно клиентскую область. Функция NeedWriteClientSize
  // определяет, нужно ли принудительно сохранять размер клиентской области.
  Filer.DefineProperty('ClientHeight', nil, WriteClientHeight, NeedWriteClientSize);
  Filer.DefineProperty('ClientWidth', nil, WriteClientWidth, NeedWriteClientSize);
end;

Второе решается сложнее: масштабирование VCL делает в методе TCustomForm.ReadState сразу после считывания DFM:

...
    inherited ReadState(Reader);
    if (FPixelsPerInch <> 0) and (FTextHeight > 0) then
    begin
      // проверка, изменилось ли значение PixelsPerInch и 
      // само масштабирование
    end;
...

В общем я решил сбросить значение приватного поля FTextHeight в 0. Чтобы это можно было сделать, необходимо вклиниться в код между строками 2 и 3. Для этого я объявил фэйковое свойство под названием ScaleFix, которое считывается самым последним у формы, выглядит это примерно так:

...
  private
    FPixelsPerInch: Integer;
    procedure WriteScaleFix(Writer: TWriter);
    procedure ReadScaleFix(Reader: TReader);
...
procedure TBaseForm.WriteScaleFix(Writer: TWriter);
begin
  // просто сохраняем флаг в DFM-файл, чтобы при его чтении можно было вклиниться в процесс метода ReadState
  Writer.WriteBoolean(True);
end;

procedure TBaseForm.ReadScaleFix(Reader: TReader);
begin
  if not Reader.ReadBoolean then
    Exit;

  // запоминаем прочитанное ранее свойство PixelsPerInch
  FPixelsPerInch := THackCustomForm(Self).FPixelsPerInch;
  // и устанавливаем текущее
  THackCustomForm(Self).FPixelsPerInch := Screen.PixelsPerInch;

  // сбрасываем свойство FTextHeight для отключения масштабирования на уровне VCL
  THackCustomForm(Self).FTextHeight := 0;
end;

procedure TBaseForm.DefineProperties(Filer: TFiler);
...
  Filer.DefineProperty('ScaleFix', ReadScaleFix, WriteScaleFix, Scaled);
end;

Ну и наконец, третье: масштабирование своей процедурой. Делается в методе Loaded.

...
  protected
    procedure Loaded; override;
...
procedure TBaseForm.Loaded;
begin
  if (FPixelsPerInch > 0) and (FPixelsPerInch <> Screen.PixelsPerInch) then
    ScaleControl(Self, Screen.PixelsPerInch, FPixelsPerInch);

  inherited Loaded;
end;

Самое интересное в процедуре ScaleControl (её код – ниже), к сожалению без хака там не обошлось, причём хак жёстко привязан к версии Delphi, т.к. необходимо получать доступ к приватным полям компонентов. Если Вы сравните мою процедуру с тем, как масштабирует VCL, вы увидите, что я не обрабатываю ScalingFlags. Дело в том, что у меня масштабирование вызывается всего один раз после полной загрузки формы в методе Loaded, и этого достаточно, т.к. масштабирование нужно применять для всех контролов. В VCL же масштабирование применяется в методе ReadState, который вызывается несколько раз – для каждого класса в иерархии, у которых есть DFM-ресурс. А такое встречается, если используется визуальное наследование. Поэтому VCL необходимо знать, масштабировался контрол, или нет, для чего и используется это свойство ScalingFlags. Этот “финт ушами” VCL использует для случая, когда разные формы в цепочке визуального наследования разрабатываются при разных значениях свойства PixelsPerInch.

Вот код самой процедуры (описание типов THackXXX можно найти в исходнике):

procedure ScaleControl(Control: TControl; MX, DX, MY, DY, MF, DF: Integer);
  procedure ScaleControlConstraints(Control: TControl);
  begin
    with THackSizeConstraints(Control.Constraints) do
    begin
      FMaxHeight := MulDiv(FMaxHeight, MY, DY);
      FMaxWidth := MulDiv(FMaxWidth, MX, DX);
      FMinHeight := MulDiv(FMinHeight, MY, DY);
      FMinWidth := MulDiv(FMinWidth, MX, DX);
    end;
    //TFriendlySizeConstraints(Control.Constraints).Change;
  end;

  {$ifdef Controls.TMargins}
  procedure ScaleControlMargins(Control: TControl);
  begin
    with THackMargins(Control.Margins) do
    begin
      FLeft := MulDiv(FLeft, MX, DX);
      FTop := MulDiv(FTop, MY, DY);
      FRight := MulDiv(FRight, MX, DX);
      FBottom := MulDiv(FBottom, MY, DY);
    end;
    //TFriendlyMargins(Control.Margins).Change;
  end;
  {$endif}

  procedure ScaleControl(Control: TControl);
  var
    L, T, W, H: Integer;
  begin
    with Control do
    begin
      // scale Left
      L := MulDiv(Left, MX, DX);
      // scale Top
      T := MulDiv(Top, MY, DY);
      // scale Width
      if not (csFixedWidth in ControlStyle) then
        W := MulDiv(Left + Width, MX, DX) - L
      else
        W := Width;
      // scale Hight
      if not (csFixedHeight in ControlStyle) then
        H := MulDiv(Top + Height, MY, DY) - T
      else
        H := Height;
    end;

    ScaleControlConstraints(Control);

    {$ifdef Controls.TMargins}
    ScaleControlMargins(Control);
    {$endif}

    {$ifdef bf_tb2k}
    // scale TTBToolWindow
    if Control is TTBToolWindow then
      with TTBToolWindow(Control) do
      begin
        MaxClientHeight := MulDiv(MaxClientHeight, MY, DY);
        MaxClientWidth := MulDiv(MaxClientWidth, MX, DX);
        MinClientHeight := MulDiv(MinClientHeight, MY, DY);
        MinClientWidth := MulDiv(MinClientWidth, MX, DX);
      end;
    {$endif}

    // apply new bounds (with check constraints and margins)
    Control.SetBounds(L, T, W, H);

    with THackControl(Control), TFriendlyControl(Control) do
    begin
      // scale OriginalParentSize
      FOriginalParentSize.X := MulDiv(FOriginalParentSize.X, MX, DX);
      FOriginalParentSize.Y := MulDiv(FOriginalParentSize.Y, MY, DY);

      // scale Font.Size
      if not ParentFont and (MF <> DF) then
        Font.Size := MulDiv(Font.Size, MF, DF);
    end;
  end;

  procedure ScaleWinControlDesignSize(WinControl: TWinControl);
  begin
    with TFriendlyWinControl(WinControl) do
    begin
      FDesignSize.X := MulDiv(FDesignSize.X, MX, DX);
      FDesignSize.Y := MulDiv(FDesignSize.Y, MY, DY);
    end;
  end;

  {$ifdef Controls.TPadding}
  procedure ScaleWinControlPadding(WinControl: TWinControl);
  begin
    with THackPadding(WinControl.Padding) do
    begin
      FLeft := MulDiv(FLeft, MX, DX);
      FTop := MulDiv(FTop, MY, DY);
      FRight := MulDiv(FRight, MX, DX);
      FBottom := MulDiv(FBottom, MY, DY);
    end;
    TFriendlyPadding(WinControl.Padding).Change;
  end;
  {$endif}

  procedure ScaleWinControl(WinControl: TWinControl);
  begin
    ScaleControl(WinControl);

    ScaleWinControlDesignSize(WinControl);

    {$ifdef Controls.TPadding}
    ScaleWinControlPadding(WinControl);
    {$endif}
  end;

  procedure ScaleScrollBars(Control: TScrollingWinControl);
  begin
    with TFriendlyScrollingWinControl(Control) do
    begin
      if not AutoScroll then
      begin
        with HorzScrollBar do
        begin
          Position := 0;
          Range := MulDiv(Range, MX, DX);
        end;
        with VertScrollBar do
        begin
          Position := 0;
          Range := MulDiv(Range, MY, DY);
        end;
      end;
    end;
  end;

  procedure ScaleScrollingWinControl(ScrollingWinControl: TScrollingWinControl);
  begin
    ScaleScrollBars(ScrollingWinControl);
    ScaleWinControl(ScrollingWinControl);
  end;

  procedure ScaleCustomFormConstraints(CustomForm: TCustomForm; cdx, cdy: Integer);
    procedure ScaleValue(var Value: TConstraintSize; M, D, s: Integer);
    var
      tmp: Integer;
    begin
      if Value > 0 then
      begin
        tmp := MulDiv(Value - s, M, D) + s;
        if tmp < 0
          then Value := 0
          else Value := tmp;
      end;
    end;
  begin
    // при масштабировании констрейнтов формы, надо учитывать
    // разницу между внешними размерами и размерами клиентской области
    with THackSizeConstraints(CustomForm.Constraints) do
    begin
      ScaleValue(FMaxWidth, MX, DX, cdx);
      ScaleValue(FMinWidth, MX, DX, cdx);
      ScaleValue(FMaxHeight, MY, DY, cdy);
      ScaleValue(FMinHeight, MY, DY, cdy);
    end;
    //TFriendlySizeConstraints(Constraints).Change;
  end;

  procedure ScaleCustomForm(CustomForm: TCustomForm);
  var
    W, H: Integer;
    cdx, cdy: Integer;
  begin
    with CustomForm do
    begin
      cdx := Width - ClientWidth;
      cdy := Height - ClientHeight;

      if MF <> DF then
        Font.Height := MulDiv(Font.Height, MF, DF);

      W := MulDiv(ClientWidth, MX, DX) + cdx;
      H := MulDiv(ClientHeight, MY, DY) + cdy;
    end;

    ScaleWinControlDesignSize(CustomForm);

    ScaleScrollBars(CustomForm);

    ScaleCustomFormConstraints(CustomForm, cdx, cdy);

    // При уменьшении размера иногда (пока не разбирался почему) новые размеры не применяются
    // Наращивание ширины и высоты на 1 пиксель помогает обойти такую проблему
    if DX > MX then
      inc(W);
    if DY > MY then
      inc(H);

    // apply new bounds (with check constraints and margins)
    with CustomForm do
      SetBounds(Left, Top, W, H);
  end;

  procedure ScaleAndAlignWinControl(WinControl: TWinControl);
  var
    SavedAnchors: array of TAnchors;
    i: Integer;
  begin
    with WinControl do
    begin
      // disable anchors of child controls:
      SetLength(SavedAnchors, ControlCount);
      for i := 0 to ControlCount - 1 do
      begin
        SavedAnchors[i] := Controls[i].Anchors;
        Controls[i].Anchors := [akLeft, akTop];
      end;

      DisableAlign;
      try
        // scale itself:
        if WinControl is TCustomForm then
          ScaleCustomForm(TCustomForm(WinControl))
        else if WinControl is TScrollingWinControl then
          ScaleScrollingWinControl(TScrollingWinControl(WinControl))
        else
          ScaleWinControl(WinControl);

        // scale child controls:
        for i := 0 to ControlCount - 1 do
          BaseForms.ScaleControl(Controls[i], MX, DX, MY, DY, MF, DF);
      finally
        EnableAlign;

        // enable anchors of child controls:
        for i := 0 to ControlCount - 1 do
          Controls[i].Anchors := SavedAnchors[i];
      end;
    end;
  end;
begin
  if Control is TWinControl then
    ScaleAndAlignWinControl(TWinControl(Control))
  else
    ScaleControl(Control);
end;

Приведённое мною решение проблемы масштабирования вполне работоспособное. Единственное требование, которое я к нему предъявляю – разработка всех форм в одном разрешении, и то, если у Вас используется визуальное наследование.

Фиксим масштабирование фрейм

С фреймой проще: нам достаточно сохранять/считывать свойство PixelsPerInch и выполнять масштабирование в том случае, если фрейма создана без родителя.

...
  private
    FPixelsPerInch: Integer;
    procedure WritePixelsPerInch(Writer: TWriter);
    procedure ReadPixelsPerInch(Reader: TReader);
  protected
    procedure DefineProperties(Filer: TFiler); override;
    procedure Loaded; override;
...
procedure TBaseFrame.WritePixelsPerInch(Writer: TWriter);
begin
  Writer.WriteInteger(Screen.PixelsPerInch);
end;

procedure TBaseFrame.ReadPixelsPerInch(Reader: TReader);
begin
  FPixelsPerInch := Reader.ReadInteger;
end;

procedure TBaseFrame.DefineProperties(Filer: TFiler);
begin
  inherited DefineProperties(Filer);

  // сохранять свойство PixelsPerInch нужно только при дизайне самой фреймы. Если фрейма встроена во что-то, то
  // тогда свойство сохранять не нужно
  Filer.DefineProperty('PixelsPerInch', ReadPixelsPerInch, WritePixelsPerInch, not Assigned(Filer.Ancestor));
end;

procedure TBaseFrame.Loaded;
begin
  // масштабируем только в том случае, если фрейма создаётся в Run-Time вручную (т.е. Parent = nil),
  // либо в дизайнере
  if (FPixelsPerInch > 0) and (FPixelsPerInch <> Screen.PixelsPerInch) and
    (not Assigned(Parent) or (csDesigning in ComponentState))
  then
    ScaleControl(Self, Screen.PixelsPerInch, FPixelsPerInch);

  inherited Loaded;
end;

Скачать

Исходники доступны на этой странице. Ссылка для быстрого скачивания.

UPD 16.05.2013 – небольшой рефакторинг + поддержка Delphi XE2

13 коммент.:

IL комментирует...

Маленькая просьба: добавить пакет для DelphiXE *150.*, чтобы не ставить пакет *140.* от Delphi2010.

Николай Зверев комментирует...

IL, я могу сделать необходимые переименования (в файлах и именах файлов). Но Delphi XE я устанавливать пока не хочу..
Можно сделать так - Вы мне напишите на eamil, я Вам отошлю пакеты, вы их потестируете (в плане устанавливаются они, или нет) и отпишитесь.. после этого я их выложу в Git

IL комментирует...

Как вариант обхода проблемы масштабирования: можно ли запретить масштабирование форм своего приложения так, чтобы при 120dpi они выглядели также, как и при 96dpi?

Николай Зверев комментирует...

IL, могу добавить глобальную переменную, по которой масштабирование будет отключаться.

IL комментирует...

Вероятно, это помогло бы если не решить проблемы масштабирования в Delphi или BaseForms, то хотя бы задвинуть проблему подальше. Особенно в D7.
Я совсем забыл, что в D7 еще проблема с уездом компонет за правый и нижний край формы в режиме Aero или в режиме рабочего стола, отличном от классического в Виста+. Возможно ли в BaseForms сделать такой фикс?

Николай Зверев комментирует...

Вот BaseForms как раз эту проблему решает, пока не совсем идеально (надо покрутить с масштабированием шрифта), но решает. Здесь важно, чтобы в DFM-формы сохранилось свойство ScaleFix, если его в DFM-нет, то сработает масштабирование на уровне VCL с указанной проблемой.

IL комментирует...

ОК, что-то меня переклинило. Почему-то я подумал, что в XE одни проблемы с масштабированием, а в 7 другие. Вообще, интересно, удастся ли решить их простым исправлением положения и размеров компонент. А еще, почему в EMBT не сделали поддержку high DPI для VCL? Понятно, что поезд уже ушел в сторону FireMonkey. Но все же VCL по-прежнему native-библиотека для Windows. В документе http://msdn.microsoft.com/en-us/library/windows/desktop/dd464660(v=vs.85).aspx вроде бы описано, что нужно сделать, чтобы приложение стало high DPI aware.

IL комментирует...

Обсуждения high-DPI конечно были https://forums.embarcadero.com/message.jspa?messageID=442224 и https://forums.embarcadero.com/message.jspa?messageID=471064
Интересная статья про DPI-aware приложения http://www.rw-designer.com/DPI-aware
Получается, у нас есть возможность поиграть свойством формы Scaled и уведомить ОС, что наше приложение DPI-aware, чтобы она не врала про DPI.

Arioch, the комментирует...

Судя по всему в Win8.1 или win8.2 будут разные PPI на разных экранах одновременно. И просто таская форму между экранами будешь вызывать ее многокартное перемасштабирование.

Похоже всё-таки тупик - попиксельно уже не получится, будут ошибки округления лезть.

Николай Зверев комментирует...

а) можно ссылку?
б) в принципе, это не криминально. По идее, сама ОС сможет масштабировать окна. Т.е. для приложения будет использоваться виртуальное PPI. С таким мы уже сталкивались в Vista/7 - флаг "Масштабировать в стиле Windows XP". Правда о качестве картинки придётся забыть.
в) чтобы минимизировать ошибки округления в VCL - можно сохранять (или перечитывать из dfm) оригинальные значения размеров при каждом изменении масштаба. Ну т.е. мне думается, костыль изобрести будет вполне реально

Arioch, the комментирует...

google "WM_DPICHANGED"

Arioch, the комментирует...

> можно сохранять (или перечитывать из dfm)

не в общем виде.

Формы могут изменять расположение - или ползователь что-тo подвинет, или программист в OnRResize....

Да, далеко не во всех формах, но тем не менее, это будет

Николай Зверев комментирует...

Разработчики Help And Manual выложили документ "A Delphi Developers Guide for 4K Displays"
http://www.helpandmanual.com/downloads_delphi.html

Отправить комментарий

.

.