Программирование на Visual C++

     выпуск No.12 от 24/07/2000

Приветствую!

/ / / / MFC / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /

Недавно мне пришло письмо с просьбой рассказать о таком элементе управления, как CTabCtrl. После того, как я отправил ответ, я подумал, что это могло бы быть интересно многим. Так что я немного переработал материал, кое-что добавил и - читайте!

CTabCtrl: Закладки как средство продуманного интерфейса

Очень часто так бывает, что все нужные элементы управления в диалог не помещаются. Или помещаются, но смотрятся очень неважно: хаотично и не всякий сразу поймет, что к чему. Правилами хорошего интерфейса в таких случаях принято делить элементы управления на логические группы, и каждую логическую группу помещать отдельно. Но что если эти логические группы сами по себе не такие уж маленькие? Тогда лучше всего сами логические группы помещать на разных страницах диалога... а откуда взять эти разные страницы, если диалог-то всего один?

Во тут-то нам и приходят на помощь закладки. Они позволяют иметь несколько страничек, и легко между ними переключаться. Посмотрите - в Windows очень много примеров применения такого подхода. Наверняка вы с ним уже встречались, и не раз. 

Итак, какие же средства предоставляет нам MFC для работы с закладками? Можно назвать три класса: CPropertySheet (вместе с CPropertyPage) и CTabCtrl. 

Первый класс (CPropertySheet) представляет собой более сложное образование, позволяющее создавать так наз. страницы свойств, готовые диалоги со стандартными кнопками и набором закладок, где вы размещаете свои элементы управления. В качестве примера можете посмотреть диалог Tools|Options в интегрированной среде Visual C++. Это полезно, если вам нужно создать именно такой диалог, например для изменения конфигурации программы. CPropertySheet представляет набор страниц, CPropertyPage - отдельную страницу такого набора. 

Но что если вам нужно получить больший контроль над закладками? Что если вам нужны только закладки, а не готовый диалог? А еще если вы хотите кроме текста в заголовках закладок рисовать иконки?

Вот тогда вам нужно воспользоваться CTabCtrl, классом более низкого уровня, чем CPropertySheet. Замечу, что сам класс CPropertySheet использует CTabCtrl, причем его можно  попросить дать вам указатель на этот объект.  Таким образом, Узная, как работать с CTabCtrl, вы одновременно узнаете, как можно на низком уровне работать с CPropertySheet. А про CPropertySheet я расскажу как-нибудь в другой раз.

Пусть вам нужно сделать закладки в существующем диалоге. Создать элемент типа CTabCtrl можно двумя способами: динамически (в программе) и в редакторе ресурсов. Для примера воспользуемся вторым способом. 
В палитре элементов найдите  "Tab Control" и поместите его в ваш диалог. Теперь два раза кликните по нему мышкой при нажатой клавише Ctrl. Вам будет предложено создать переменную класса, соглашайтесь. Введите m_Tab  в качестве имени и CTabCtrl в качестве типа.

По умолчанию наш объект пока не содержит ни одной закладки. Чтобы они появились, их необходимо создать с помощью функции InsertItem( ). Это можно сделать в OnInitDialog( ):

BOOL CTabDlg::OnInitDialog()
{

  TC_ITEM tci;   // в нее записываются параметры создаваемой закладки

  memset(&tci,0,sizeof(tci));
  tci.mask = TCIF_TEXT;    // у закладки будет только текст

  tci.pszText = "Закладка 1"; // название закладки
  m_Tab.InsertItem(0, &tci); // первая закладка имеет индекс 0

  tci.pszText = "Закладка 2";
  m_Tab.InsertItem(1, &tci);  // вставляем вторую закладку

  return TRUE;
}

Ну вот, у нас есть две закладки. Теперь нам нужно поместить что-нибудь внутрь. 

Прежде всего, для каждой из закладок нужно создать диалог, который будет отображаться при выборе этой закладки. Например, создайте для начала два диалога - IDD_TABPAGE1 и IDD_TABPAGE2. В свойствах каждому поставьте тип "Child" - "дочерний" (properties|styles|style:Child) и "Без рамки" (properties|styles|border:None). Для каждого диалога нужно создать соответствующий класс. Это можно сделать, два раза кликнув по поверхности диалога в редакторе. У меня получились классы СTabPage1 и CTabPage2.

Нужные контролы и обработчики в диалоги можно поместить на данном этапе, а можно и потом (хотя для оценки размеров лучше все-таки это сделать сейчас. Потом можно будет внести любые изменения). Но для тестирования какие-нибудь отличительные знаки в них поставить нужно обязательно , а то вы просто не узнаете, какие диалоги где у вас выводятся - все будут одинаковые.

В классе вашего диалога, кому принадлежит TabCtrl ( в примере- CTabDlg) добавьте переменную-указатель на текущий диалог:

protected:
 CTabCtrl m_Tabs;
 CDialog* m_pTabDialog; // <--- добавить

В конструкторе класса проинициализируйте ее в 0:

CTabDlg::CTabDlg(CWnd* pParent /*=NULL*/)
   : CDialog(CTabDlg::IDD, pParent)
{
  m_pTabDialog=0;
}


Зайдите в ClassWizard и для TabCtrl добавьте обработчик TCN_SELCHANGE (изменение закладки). 
Теперь мы будем динамически удалять прошлый диалог/ создавать новый и выводить его в TabControl.

Вот как это выглядит:

void CTabDlg::OnSelchangeTab1(NMHDR* pNMHDR, LRESULT* pResult)
{
  int id; // ID диалога

  // надо сначала удалить предыдущий диалог в Tab Control'е:
  if (m_pTabDialog)
  {
    m_pTabDialog->DestroyWindow();
    delete m_pTabDialog;
  }

  // теперь в зависимости от того, какая закладка выбрана, 
  // выбираем соотв. диалог

  switch( m_Tab.GetCurSel()+1 ) // +1 для того, чтобы порядковые номера закладок
                                // и диалогов совпадали с номерами в case

  {
    // первая закладка
    case 1 :
          id = IDD_TABPAGE1;
          m_pTabDialog = new CTabPage1;
            // тип указателя соответствует нужному диалогу,
            // иначе добавленный в диалог код не будет функционировать

    break;

    // вторая закладка
    case 2 :
          id = IDD_TABPAGE2;
          m_pTabDialog = new CTabPage2;
    break;

    // все остальные закладки, если они есть,
    // будут здесь тоже представлены, каждая - отдельным case

    // а если обработка такого номера не предусмотрена

    default:
       m_pTabDialog = new CDialog; // новый пустой диалог
       return;

   } // end switch

  // создаем диалог
  m_pTabDialog->Create (id, (CWnd*)&m_Tabs); //параметры: ресурс диалога и родитель

  CRect rc; 

  m_Tab.GetWindowRect (&rc); // получаем "рабочую область"
  m_Tab.ScreenToClient (&rc); // преобразуем в относительные координаты

  // исключаем область, где отображаются названия закладок:

  m_Tab.AdjustRect (FALSE, &rc); 

  // помещаем диалог на место..
  m_pTabDialog->MoveWindow (&rc);

  // и показываем:
  m_pTabDialog->ShowWindow ( SW_SHOWNORMAL );
  m_pTabDialog->UpdateWindow();

  *pResult = 0;
}


Теперь последний штрих: в OnInitDialog( ) нужно добавить следующий код:

 
...
 m_Tab.InsertItem(1, &tci); 

 //-----------------
 // добавить:

 NMHDR hdr;

 hdr.code = TCN_SELCHANGE;
 hdr.hwndFrom = m_Tab.m_hWnd;

 SendMessage ( WM_NOTIFY, m_Tab.GetDlgCtrlID(), (LPARAM)&hdr );

 //-----------------

 return TRUE;
}

Это необходимо для того, чтобы отобразить самую первую закладку.

Как вариант можно просто вызвать OnSelchangeTab1(0,0); но тогда из OnSelchangeTab1 нужно удалить последнюю строку (*pResult=0).

Можете вволю поэксперементировать со свойствами и стилями CTabCtrl. Мне, например, очень нравятся закладки, надписи на которых подсвечиваются при наведении курсора мыши, кстати это имеет место в MS Access 97 (стиль TCS_HOTTRACK). 

И еще: не забудьте, если диалог у вас немодальный, вы должны обеспечить корректный обмен данными между активным диалогом в Tab Control и вашим приложением. Это делается точно так же, как и обычный обмен данными с немодальным диалогом.

/ / / / ОБРАТНАЯ СВЯЗЬ  / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / /

Небезызвестный вам Борис Бердичевский (см. выпуск No. 3) делится своим решением часто возникающей проблемы с сериализацией.

Приветствую!

Случилось так, что мне пришлось потратить целый рабочий день на поиск одного решения в области сериализации; готов поделиться.
Как я уже отмечал недавно, при сериализации данных нужно решать проблему совместимости старых структур сохраняемых данных, т.е. если вы добавили новое поле в какой-то класс, подлежащий сериализации, будьте добры, обеспечьте чтение этого класса, ранее сохраненного без этого поля.
Способ, который я приводил (ReadClass/WriteClass), вполне хорош. Но закавыка-то в том, что в предыдущей версии я по какой-то причине (просто по неопытности) сохранял без WriteClass! А имплементация сериализации была описана как IMPLEMENT_SERIAL(MyClass, CObject, 0)

Итак, подобным образом сериализованный класс надо было успешно прочитать. Понятное дело, ReadClass на такую сериализацию вызывает CArchiveException с кодом CArchiveException::badIndex (=5, для справки)

Казалось бы, лови CArchiveException и обрабатывай себе, но не тут-то было! Вроде незначительная проблема: указатель архива продвигается, и невозможно уже прочитать данные из-за смещения. Никакого средства для возврата указателя на место для CArchive не существует! (Я бы мог попросить уважаемых читателей порыться, но заранее заявляю: бессмысленно! Говорите, можно манипулировать с функцией Seek принадлежащего
CArchive классу CFile? - пробовал, не работает.)

По счастью, нашлась недокументированная возможность. Выяснилось, и это очень важный нюанс, который не разъяснен в MSDN: У функции CReadClass есть 3 параметра, 2 последних имеют умолчание NULL:

CRuntimeClass* ReadClass( const CRuntimeClass* pClassRefRequested = NULL,
                     UINT* pSchema = NULL, DWORD* obTag = NULL );
Throw CArchiveException;
Throw CNotSupportedException;


Первый параметр - RuntimeClass для проверки на соответствие загружаемого класса.
Второй параметр - поинтер на UINT - версию сериализуемого класса.
Третий параметр, и это самое интересное (кстати, в MSDN записано, что, он предназначен для внутреннего использования в функции ReadClass и обычно задается как NULL) - если не задан нулем, возвращает в младших 2-х байтах значение, сериализованное из архива, а CArchiveException при этом не вырабатывается! Версия при этом не заполняется.

Отсюда решение, которое проиллюстрировано во фрагменте кода:

#define BASE_DATA_VERSION 0x100

IMPLEMENT_SERIAL(MyClass, CObject, VERSIONABLE_SCHEMA | BASE_DATA_VERSION)

void MyClass::Serialize( CArchive& ar)
{
  UINT Version=NULL;
  CObject::Serialize(ar);
  DWORD Tag;
 
if(ar.IsLoading())
  {
   TRY
    {
      ar.ReadClass(RUNTIME_CLASS(MyClass), &Version, &Tag);
     
if( Version == BASE_DATA_VERSION)
           ar >> dwValue;
// описано в классе DWORD dwValue;
     
else
      {
        WORD HighW;
        ar >> HighW;
        dwValue = MAKELONG(Tag, HighW);
      }

   ar.Read( title,
sizeof(title)); // описано в классе
        
// char title[LEN_TITLE];
   ar >> val1;
   ar >> val2;
// нормальная сериализация
...

- Борис Бердичевский

___________________

Здравствуйте Алекс!

Только что подписался на вашу рассылку, и прочитал все выпуски из архива. Мне показался интересным вопрос из выпуска N2 про возможность структурного сохранения данных в MFC.
На мой взгляд опубликованные ответы были не очень точны, т.к. в MFC все-таки имеются некоторые способы такого хранения, хотя конечно не совсем до конца доделанные.
Прежде всего существует класс COleDocument, который позволяет хранить данные в compound file - в которых как раз и хранятся иерархические данные.
У этого класса есть, к сожалению не документированный, член m_lpRootStg который представляет из себя корневой storage документа. К сожалению в MFC нет стандартных средств работы со storage, но никто не мешает пользоваться его собственным интерфейсом.
Зато есть класс COleStreamFile который инкапсулируют в себе IStream являясь в то же время потомком CFile, что делает возможным его использования для CArchive и соответственно serialize.
И наконец последнее замечание. К сожалению, модель MFC такова, что при использовании своих IStorage объектов, их НЕОБХОДИМО записывать в функции  serialize, иначе они могут потеряться при команде Save As.

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

С уважением,

- Nick Pisanov

Спасибо Нику за ответ и полезную информацию. Я  просто хочу заметить, что все-таки вместо "недоделанных" способов чаще предпочитаю использовать свои, хоть с потом и кровью созданные, но доделанные, удобные, не основывающиеся на недокументированных возможностях, досконально известные и работающие на все 100%. 
Но это вопрос философский, конечно... Иногда действительно это приводит к изобретению велосипеда. Программирование - это все-таки больше искусство, чем наука ;) Каждый творит по-своему.

___________________

Пришло дополнение к прошлому выпуску:

Hello!

Маленькое замечание. После Shell_NotifyIcon ( NIM_ADD, &nid ); надо еще добавить ::DestroyIcon( nid.hIcon );

Я как-то делал анимацию иконки в трэе (типа как TheBat крылышками там машет) и долго не мог понять, почему после нескольких часов работы прога вешает всю систему, а никакой утечки памяти нет.

- Андрей, Норильск

Огромное спасибо, Андрей! Действительно, если делать такие анимации в tray, то своевременное уничтожение иконки становится критичным. 

И еще на тему прошлого выпуска:

В 11 выпуске был вопрос, касаемо часов и реестра. Дело не в тебе, а в авторе сообщения. Дело в том, что windoza ставит в  этот ключ что-то только если user что-то поменял в разделе "язык и стандарты". Если же там всё по умолчанию, то в реестре у  тебя будет по этому пути только параметр Locale, указывающий код страны, правила записи даты, времени и т.п. которой  используются для вывода системной даты.

-  Пригожев Александр (alexproger)

Что ж, видимо так оно и есть. Я дописал нужные параметры и все заработало. Но только после того, как я изменил региональный стандарт с русского на английский(США). Те два параметра, в которых вы записывали свое имя - на самом деле это метки "до полудня" и "после полудня ", по умолчанию равные "AM" и "PM". В русском стандарте эти метки не используются.


 
/ / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / 

 ПРОШУ ВНИМАНИЯ :

Это последний выпуск рассылки в этом сезоне. К сожалению, я не знаю никого, кому мог бы доверить вести рассылку на время моего отпуска (и кто бы горел желанием это делать), поэтому рассылка также уходит в отпуск до конца лета. 
Большая просьба не ждать от меня ответа на свои письма  раньше сентября - я буду отсутствовать физически. Прошу прощения у тех, на чьи письма и вопросы не успел ответить.

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

до встречи в новом сезоне! Оставайтесь с нами!

 

©Алекс Jenter    mailto:jenter@mail.ru
Красноярск, 2000.

[ Предыдущие выпуски | Статистика рассылки ]