В первой части, через боль и страдания, мы написали ядро – основную логику макроса.
Сегодня поработаем над пользовательским интерфейсом.
У нас будет одна единственная форма, SettingsForm
, со всеми нужными настройками и кнопкой для запуска. Саму форму и все, что будет с ней связано, поместим в папку UI.
Накидываем контролы
Я, обычно, выставляю фон на Button Face, шрифт Segoe UI 9pt. Так как мне нужно, чтобы шрифт применился для всей формы, я выставляю его до добавления контролов.
После, добавляю Frame, чтобы визуально выделить содержимое формы.
Получаем вот такой результат:
Я еще люблю добавлять иконку на формы. Так можно обозначить для чего нужна конкретно эта форма (настройки, редактирование чего-либо и т.д.), или выделить главное окно макроса, сделать его немного отличным от других.
Есть парочка сайтов с бесплатными иконками, которыми можно воспользоваться.
Для нашего проекта я решил выбрать такую (формат png):
А теперь небольшой лайфхак
UserForm не поддерживает многие форматы изображений, в том числе png, а именно такого формата у нас иконка. Так же, если вставить картинку через интерфейс редактора, то скорее всего сильно потеряется качество. Я нашел такой способ, как убить двух зайцев разом:
-
Вставляем картинку на лист Excel. В разных версиях путь может отличаться, у меня так:
Меняем размер картинки. Его нужно подогнать под размер контейнера картинки (Image) в форме. В моем случае это: 1.57х1.57 см
Выделям и копируем через Ctrl+C
Далее, вставляем на форме элемент Image, убираем границы и фон (fmBorderStyleNone, fmBackStyleTransparent). Выставляем размер, у меня это 42х42.
-
В свойствах мышкой делаем активным пункт Picture и вставляем через Ctrl+V картинку из буфера:
Если с размером ошиблись, прогоняем заново, пока результат не устроит.
Результат:
В итоге, после не хитрых манипуляций, у меня получилось такое окно (картинка на кнопку ставится по описанному выше принципу):
По времени заняло минут 5-10. Чем больше таких окон делать, тем быстрее набьется рука.
Внизу разместил кнопку. Стандартный цвет кнопок не люблю менять, поэтому чтобы визуально она не терялась, оттенил фон. Стиль элементов flat. Выглядит менее угловато, чем стандартный. Но это все вкусовщина.
За границу формы я вынес кнопку со свойством Cancel = True
. Дело в том, что я настолько привык закрывать различные окна внутри программ с помощью клавиши Esc, что мне кажется это максимально логичным действием. Так вот это свойство как раз реагирует на нажатие Esc и вызывает Click event кнопки, на которой оно установлено. На это событие мы вешаем Unload Me
, чтобы закрыть форму и кайфуем. Теперь можно закрывать форму по нажатию на Esc.
Private Sub CloseCommandButton_Click()
Unload Me ' Вешаем на событие выгрузку форму
End Sub
Config
Когда мы делаем форму, мы подразумеваем, что некоторые или даже все элементы на ней будут сохранять свое состояние даже после выхода из Excel. Например очень удобно бывает один раз указать путь к файлу и больше не менять это значение (если файл просто перезаписывается).
Моя первая статья на habr как раз была про то, как можно хранить конфигурацию.
С момента написания этой статьи прошло достаточно времени, и я немного отошел от этого варианта и храню конфигурацию в реестре с помощью стандартных средств VBA. Это позволяет не писать логику чтения и записать данных (они уже написаны), а так же дает возможность передавать новые версии файла с макросом от сотрудника к сотруднику не сбивая личные пользовательские настройки.
Как уже стало понятно из первой части – я люблю обертки.
Поэтому, для работы с конфигом мы напишем небольшую обертку.
Создадим класс Config
и модуль конструктор (подробнее про конструктор тут), и добавим в него пару методов:
' Модуль ConfigCstr
Public Function NewConfig(ByVal AppName As String) As Config
Set NewConfig = New Config
NewConfig.SetAppName AppName
End Function
' Класс Config
Private Type TConfig
AppName As String
Section As String
End Type
Private this As TConfig
Public Function SetAppName(ByVal Name As String) As Config
this.AppName = Name
Set SetAppName = Me
End Function
Public Function SetSection(ByVal Name As String) As Config
this.Section = Name
Set SetSection = Me
End Function
Public Sub SetValue(ByVal Key As String, ByVal Value As String)
CheckSettings
SaveSetting this.AppName, this.Section, Key, Value
End Sub
Public Function GetValue( _
ByVal Key As String, _
Optional ByVal DefaultValue As Variant = Empty, _
Optional ByVal CastType As VbVarType = VbVarType.vbString _
) As Variant
CheckSettings
GetValue = Cast(GetSetting(this.AppName, this.Section, Key, DefaultValue), CastType)
End Function
Private Sub CheckSettings()
If Len(this.AppName) = 0 Then Err.Raise 91, TypeName(Me), "Не задана переменная AppName."
If Len(this.Section) = 0 Then Err.Raise 91, TypeName(Me), "Не задана переменная Section."
End Sub
Private Function Cast(ByVal Value As Variant, ByVal CastType As VbVarType) As Variant
Select Case CastType
Case vbString: Cast = CStr(Value)
Case vbBoolean
On Error Resume Next
Cast = CBool(Value)
Cast = iif(Err.Number = 0, Cast, false)
End Select
End Function
Стандартные методы Set
и Get
Value
, для установки/получения значения. Здесь я добавил возврат текущего инстанса класса, для того, чтобы можно было менять эти значения и записывать настройки в одну строку, типа:
Config.SetAppName("SomeApp").SetSection("Section").SetValue "SomeKey", "SomeValue"
Так же, есть два вспомогательных метода CheckSettings
и Cast
.
Первый для проверки, что мы установили имя приложения и секцию настроек, куда мы вносим конкретные ключ/значение. Можно, конечно, их вписать жестко, но лучше стараться писать переиспользуемые модули, если это возможно, чтобы они не зависели от конкретного проекта. Конфиг как нельзя лучше подходит на эту роль.
Cast
– вспомогательная функция для приведения к какому либо типу нашего значения. Возьмем стандартный enum VbVarType
для выбора к какому типу приводить. Я реализовал только два типа, так как именно они нужны будут дальше. Преобразование boolean немного необычное, так как, к примеру, null или "" нельзя перевести в boolean, поэтому если была ошибка, мы будем возвращать False
.
Ну и функция конструктор, куда ж без нее.
FormController
Раньше я писал всю логику и обработку формы в модуле самой формы, но в итоге все это приводило к ужасной мешанине несвязного между собой кода. Поэтому SettingsFormController
(с конструктором, есессно):
' Модуль SettingsFormControllerCstr
Public Function NewSettingsFormController(ByRef Form As MSForms.UserForm, ByRef Config As Config) As SettingsFormController
Set NewSettingsFormController = New SettingsFormController
Set NewSettingsFormController.Form = Form
Set NewSettingsFormController.Config = Config
End Function
' Класс SettingsFormController
Private Type TSettingsFormController
Config As Config
Form As MSForms.UserForm
End Type
Private this As TSettingsFormController
Public Property Set Config(ByVal RHS As Config)
Set this.Config = RHS
End Property
Public Property Set Form(ByVal RHS As MSForms.UserForm)
Set this.Form = RHS
End Property
Public Sub LoadValues()
Dim Control As MSForms.Control
For Each Control In this.Form.Controls
If Strings.Len(Control.Tag) = 0 Then GoTo Continue
Dim CastType As VbVarType
If IsTextBox(Control) Then
CastType = vbString
ElseIf IsOptionButton(Control) Then
CastType = vbBoolean
Else
GoTo Continue
End If
Dim Value As Variant
Value = this.Config.GetValue(Control.Tag, CastType:=CastType)
If IsEmpty(Value) Then GoTo Continue
Control.Value = Value
Continue:
Next
End Sub
Public Sub SaveValues()
Dim Control As MSForms.Control
For Each Control In this.Form.Controls
If Not IsTextBox(Control) _
And Not IsOptionButton(Control) Then GoTo Continue
Me.SaveValue Control
Continue:
Next
End Sub
Public Sub SaveValue(ByRef Control As Control)
this.Config.SetValue Control.Tag, Control.Value
End Sub
Логика простая, с помощью этого класса мы даем интерфейс для сохранения значения/значений элементов управления и для загрузки из конфига.
В нашем конкретном случае нам нужны только TextBox и OptionButton, поэтому в Utils
добавляем две вспомогательные функции, чтобы отсекать все остальные элементы управления:
Public Function IsTextBox(ByVal Control As MSForms.Control) As Boolean
IsTextBox = TypeName(Control) = "TextBox"
End Function
Public Function IsOptionButton(ByVal Control As MSForms.Control) As Boolean
IsOptionButton = TypeName(Control) = "OptionButton"
End Function
Важный элемент – тэги. Их мы будем использовать для удобного обращения к конфигу:
' Модуль FormTags
Public Const FilePath As String = "FilePath"
Public Const GT20Collector As String = "GT20Collector"
Public Const LT20Collector As String = "LT20Collector"
В модуле формы, создаем обработчики:
Private Type TSettingsForm
Controller As SettingsFormController
End Type
Private this As TSettingsForm
Private Sub UserForm_Initialize()
SetTags
Set this.Controller = NewSettingsFormController( _
Form:=Me, _
Config:=NewConfig(Constants.APP_NAME_ENG).SetSection("Settings") _
)
this.Controller.LoadValues
End Sub
Private Sub SetTags()
Me.FilePathTextBox.Tag = FormTags.FilePath
Me.GT20OptionButton.Tag = FormTags.GT20Collector
Me.LT20OptionButton.Tag = FormTags.LT20Collector
End Sub
Private Sub SelectFilePathCommandButton_Click()
Dim Path As Variant
Path = Application.GetOpenFilename( _
Title:="Укажите путь к файлу отчета", _
FileFilter:="Excel Файлы (*.xls*), *.xls*" _
)
If IsBoolType(Path) Then Exit Sub
Me.FilePathTextBox.Value = Path
End Sub
Private Sub RunCommandButton_Click()
this.Controller.SaveValues
App.Main
Unload Me
End Sub
Private Sub CloseCommandButton_Click()
Unload Me
End Sub
' Модуль Utils
Public Function IsBoolType(ByVal Value As Variant) As Boolean
IsBoolType = VarType(Value) = vbBoolean
End Function
При инициализации привязываем тэги, создаем новый контроллер и передаем в него саму форму плюс конфиг с уже выставленной секцией Settings (помните же, что метод
SetSection
возвращает инстанс текущегоConfig
объекта?). Вызываем метод загрузки значений.При нажатии на кнопку выбора файла, вызываем стандартный диалог
GetOpenFileName
и проверяем полученный тип результата, если boolean – выходим из процедуры, т.к. пользователь нажал кнопку Отмена. В противном случае записываем в TextBox полученной значение.При нажатии на кнопку Выполнить, сохраняем все текущие значения, вызываем
Main
процедуру и выгружаем форму.
Собственно, почти все, осталось еще добавить проверку перед запуском на то, что у нас все корректно заполнено и выбран хотя бы один сборщик:
' Класс SettingsFormController
Public Function IsPrepairedToRun(ParamArray Controls() As Variant) As TCheckResult
Dim OptionSelected As Boolean
Dim Control As Variant
For Each Control In Controls
If IsTextBox(Control) Then
If Strings.Len(Control.Value) = 0 Then
IsPrepairedToRun.HasError = True
IsPrepairedToRun.Message = "Не указан путь к файлу."
Exit Function
ElseIf Not NewFileSystemObject().FileExists(Control.Value) Then
IsPrepairedToRun.HasError = True
IsPrepairedToRun.Message = "Указанный файл не найден."
Exit Function
End If
ElseIf IsOptionButton(Control) Then
If Not OptionSelected Then OptionSelected = Control.Value
Else
GoTo Continue
End If
Continue:
Next
If Not OptionSelected Then
IsPrepairedToRun.HasError = True
IsPrepairedToRun.Message = "Не выбран ни один тип сборки."
End If
End Function
Получаем элементы управления для проверки и в цикле проходим по каждому.
В случае с TextBox проверяем что значение не пусто и то что указанный файл существует, в противно случае добавляем сообщение об ошибке.
В случае с OptionButton записываем в переменную OptionSelected значения опций до тех пор, пока она не станет True и в конце функции проверяем, выбран ли хотя бы один тип сборки.
В форме корректируем обработчик нажатия на кнопку Выполнить:
Private Sub RunCommandButton_Click()
this.Controller.SaveValues
Dim Check As TCheckResult
Check = this.Controller.IsPrepairedToRun( _
Me.FilePathTextBox, _
Me.GT20OptionButton, _
Me.LT20OptionButton _
)
If Check.HasError Then
MsgBox Check.Message, vbExclamation, "Ошибка запуска"
Exit Sub
End If
App.Main
Unload Me
End Sub
Проверяем:
Осталось научить макрос читать настройки.
Добиваем логику
Напомню, нам нужно заполнить переменные FilePath
и Kind
.
Сначала инициализируем Config
в Main
процедуре. С FilePath
все просто, нам нужно лишь достать конкретно значение пути из конфига, а вот для типа сборщика мы напишем доп функцию в модуле CollectorTypes
:
Public Enum CollectorKind
GT20
LT20
End Enum
Public Function GetCollectorKind(ByRef Config As Config) As CollectorKind
If Config.GetValue(FormTags.GT20Collector, CastType:=vbBoolean) Then
GetCollectorKind = GT20
ElseIf Config.GetValue(FormTags.LT20Collector, CastType:=vbBoolean) Then
GetCollector = LT20
Else
Err.Raise 9, "GetCollectorKind", "Выбран неизвестный тип сборщика."
End If
End Function
Почему здесь? Потому что в случае если нужно будет добавить новый тип в enum, мы тут же добавим логику в функцию. Удобно же.
Пара штрихов
Это еще не все. Во-первых, нам нужно поставить напоминалку о DEV режиме. Сделать это можно несколькими способами, но я люблю добавлять MsgBox
с соответствующими сообщениями в Main
(чтобы очередной раз себе напомнить) и в событие перед закрытием книги (чтобы случайно не отдать клиенту dev-версию):
' ЭтаКнига
Private Sub Workbook_BeforeClose(Cancel As Boolean)
#If DEV Then
Dim Confirm As VbMsgBoxResult
Confirm = MsgBox( _
"Книга находится в режиме разработки. Продолжить?", _
vbQuestion + vbYesNo, _
"DEV MODE" _
)
Cancel = Confirm = vbNo
#End If
End Sub
' Модуль App
Public Sub Main()
On Error GoTo Catch
#If DEV Then
MsgBox "Макрос запущен в режиме разработки.", vbInformation, "DEV MODE"
#End If
Utils.DisableSettings
' ....
End Sub
Во-вторых, нам нужен еще один модуль, для обработки нажатия на кнопку. Мы не будем использовать ActiveX, а возьмем кнопку из элементов управления формы, а она должна сослаться на существующую процедуру:
' Модуль ButtonHandlers
'@Folder("GoodsCollectorProject.src.UI")
Option Explicit
Public Sub ShowSettings_Click()
SettingsForm.Show
End Sub
Вот теперь можно добавить кнопку, скрыть все лишнее и выдыхать, мы закончили:
Итоги
На этом наш рефакторинг закончен. Мы написали довольно много кода, но бОльшую его часть с легкостью можем переиспользовать в других проектах, сохранив лаконичность. Так же, мы оставили задел на будущее, для удобного масштабирования логики.
Итоговый вид проекта:
Бонус, итоговый вид проекта без Rubberduck
Спасибо, что прочитали до конца! ? Надеюсь было интересно и познавательно.
Код из статьи: GitHub
Мой телеграм: Дневник VBAшника
Robastik
Почему именно макросом решать эту задачу? (Раз уж осмысленность заявлена)
В сравнении с решением в Power Query выглядит как аццкий оверхед.