2021/09/05

DVD等の内容・保管場所等管理システム




1.背景

気に入ったテレビ番組をレコーダで録画し、ブルーレイディスクやDVDディスク(昔だったらビデオ)として保管している方は多くいらっしゃると思います。私も映画やドラマなど、いつの間にか大量のディスクが色々な箱・棚に溜っています。
整理をしないといけない とは思うのですが、どのように分類したら良いのか、また見たいものがすぐに取り出せるようにするにはどうすれば良いのか・・・色々考えた末、レコーダ内の録画分類を参考にして下記のようなものを作成しました。

もちろんジャンル(分類分け)等を見直せば、録画ディスクだけでなく音楽CD・書籍の管理にも使えると思います。また今回システム作成には、今まで使ったことのなかったExcelの「ListObject(テーブル)」機能を使ってみたい との理由もありました。

なお、同じくListObjectを使った以下の項も参考にして頂けると助かります。
先行予約可能な備品予約・貸出システム
ToDoリストで個人タスク管理
会社番号検索システム

2.システム概要

2-1.システム起動

本システムは、以下の2通りの使い方を想定しています。
 (A)アドイン登録を行い、Excel起動時ならいつでも利用可。
 (B)データファイル(.xlsm)を開き、シート上のボタンから起動。直接のデータ操作も可。
アドイン登録(登録方法は「アドインとしてExcelにマクロを登録」を参照下さい)をした場合には、図2-1のようにリボン上のボタン①をクリックすることで、操作ダイアログ②が表示されます。
アドインからシステム起動
図2-1

また一番下の「サンプルファイル」のようなデータファイルを直接開く場合は、図2-2のようにシート上のボタン①に起動マクロを登録することで、システムを起動します。
サンプルファイルでのシステム起動
図2-2

2-2.新規登録

操作ダイアログを使用してデータを新規登録するには、図2-4のように各項目を入力・選択します。項目は、以下の5つです。
なお、必須入力項目は「タイトル」のみとしています。
項目入力場所入力方法・制限
タイトル図2-4の③文字種・文字数に制限はありません。
尚タイトルは必須項目で、「スペースのみ」のようなものもNGです。
シリーズ図2-4の④コンボボックスから選択します。
リストに無いものは、リスト最上段の「(新規)」をクリックし、表示されたインプットボックスに入力します(図2-4右の⑦⑧と同等)。また、コンボボックスのテキスト部に直接入力してもOKです。
作成日図2-4の⑤「yyyy/m/d」「yyyy-mm-dd」や「m/d」のような日付形式で入力して下さい。テキストボックスには文字種制限を掛けており、数字または「/」「-」しか受け付けません。
またテキストボックスを離れる時に日付チェックを行い、日付形式で無い場合は修正を求められます。
ジャンル図2-5の⑨12種のジャンルの中から、対応するチェックボックスにレ点を付けて下さい。
検索時にはORで判定しますので、「対応しそうなジャンルにはチェック」する感覚で良いと思います
保管場所図2-4の⑥コンボボックスから選択します。
リストに無いものは、リスト最上段の「(新規)」をクリックし、表示されたインプットボックスに入力します(図2-4右の⑦⑧)。また、コンボボックスのテキスト部に直接入力してもOKです。
図2-3
新規入力の箇所1
図2-4
新規入力の箇所2
図2-5

なお「No.欄」にも入力は可能ですが、新規登録時には「無視」されます。「No.欄」は、次で説明する「検索」時には検索対象になります。

入力後、図2-5⑩の「登録/更新」ボタンをクリックすると、「登録した旨のメッセージ⑪」が一瞬(今回設定では0.5秒間)表示され、データがテーブルに登録⑫されます。同時にダイアログ下側のリストボックスに登録データが表示⑬されます。
(正確には入力項目で検索した結果をリスト化していますので、同じ内容のものを複数入力すると複数行が表示されます。)
新規入力後の状態
図2-6

2-3.検索

検索を行うには、操作ダイアログの上部入力欄に「検索条件」を入力した後、「検索ボタン⑭」をクリックします。検索結果は、ダイアログ下部のリストボックス⑮に表示されます。
検索条件入力と検索結果
図2-7

項目検索方法・制限
タイトルタイトルの一部を入力することで、部分検索します。
シリーズコンボボックスのリストから選択します。また、コンボボックスのテキスト部に直接入力してもOKです。
なお「相棒2」「相棒3」の両方のシリーズを検索するには、リスト最上段の(新規)をクリックし、表示されたインプットボックスに「相棒」と登録すれば、「相棒」を含むシリーズが検索されます。
作成日この欄は、日付形式のみしか受け付けません。「年だけの入力」や「年月の入力」「期間」には今回対応させていません。
No.ディスクに登録してある「DVD番号」を入力します。DVD番号は正の整数のみです。
ジャンル12種の中から、検索するジャンルにレ点を付けて下さい。「ジャンル間はOR検索」しますので、例えば「ドラマ」と「映画」にチェックをすれば、「ドラマ」または「映画」が検索されます。
保管場所コンボボックスのリストから選択します。この欄は、オリジナル状態では完全一致検索です。
部分一致に改造した場合は、コンボボックスのテキスト部への直接入力、またはリスト最上段の(新規)をクリックし、インプットボックスに検索文字列を入力して下さい。
図2-8

「ジャンル内はOR検索」ですが、「各項目間はAND検索」になります。
ですので例えばタイトルに「相棒」を、シリーズに「ブラタモリ系」を選択して検索すると、タイトルが「相棒」を含み、且つシリーズが「ブラタモリ系」であるデータが検出されます。(通常だと系統が異なっているため、リストボックスには何も表示されない事になります。)

検索条件に何も入力しない状態で「検索ボタン⑭」をクリックすると、無条件のため「全データ」がリストボックスに表示されることになります。データが多い場合には表示までに少し時間がかかります(約1000件の場合、私のPCで約5秒)。

検索ボタンの右側にある「クリア」ボタン(図2-9の⑯)をクリックすると、操作ダイアログ上部の入力欄、及び下部のリストボックスのデータは全て消去されます。
クリアボタン
図2-9

2-4.更新

検索を行った後、図2-10左のように、操作ダイアログ下段リストボックスの「検索結果」の1つをクリックすると、選択項目の内容がダイアログ上段に反映されます。ちょうど、新規にデータを入力した時と同様の状態になります。
検索実行と項目選択
図2-10

その状態で上段の各項目を修正し、「登録/更新」ボタン⑰をクリックすると、データが修正されます。
例えば図2-11の左のように、リストボックスで選択した項目のタイトルに修正を加えます。その後「登録/更新」ボタン⑰をクリックすると、一瞬(今回設定では0.5秒)更新をした旨のメッセージが表示されます。
データが修正された後の操作ダイアログは、上段の各項目欄は「元の検索状態(=図2-10の左側と同じ状態)」に戻り、下段の検索結果のリストには「データ修正後のリスト」に置き換わります。
データ修正
図2-11

なお、複数項目を同時に修正することもOKですし、シリーズや保管場所(コンボボックス)の変更、ジャンルのチェックのON-OFFも全て修正出来ます。
ただし「No.欄」だけは変更できないように「編集不可状態」にしてありますので、DVD番号の変更は不可です。

2-5.複製・登録

ディスクの中には、ほぼ同じ内容で一部だけちょっと違うものもあると思います。リスト内で選択した項目をクリックすると、ダイアログ上段で各項目の修正が可能になることは説明しましたが、その状態から更に、図2-12左のようにリスト項目をダブルクリックすることで、上段のデータはそのままで「新規登録が出来る状態」にすることができます。
データの複製
図2-12

その状態から「ちょっとだけ違う部分」を修正した後、図2-12右の「登録/更新⑱」をクリックすることで、図2-4~図2-5で各項目を最初から入力したのと同じように「新規登録」されます。
「データ更新」時は「DVD番号の修正」は出来ない旨を説明しましたが、このデータ複製・登録で「複製データ(DVD番号だけは別)」を作成し、古いDVD番号のデータを次で説明する「データ削除」することで、「DVD番号を修正」するのと同じことが可能になります。

2-6.削除

検索を行った後、図2-13左のように、操作ダイアログ下段リストボックスの「検索結果」の1つをクリックすると、選択項目の内容がダイアログ上段に反映されるのは「データ更新」と同じですが、その状態で図2-13右のように「削除ボタン⑲」をクリックすることで、選択した項目が削除されます。
データの削除
図2-13

削除ボタン⑲をクリックした途端にデータが削除される訳では無く、図2-14左のような「削除しても良いか否か」のステップを踏むことで、誤って削除するミスを防止しています。なお削除された時には、図2-14右のようなコメントが一瞬表示されます。
削除時のメッセージ
図2-14

3.プログラムの流れ

まず、ユーザーがどのような流れでデータ入力・データ操作・ボタン操作を行うか、また、その時にどんなデータが表示されるかなどを図3-1にまとめてみました。システム起動直後は左下の「初期」の位置になります。
操作の手順と表示の流れ
図3-1

複雑に見えますが、「システム概要」で示した使い方手順に合わせた流れになっています。この流れをボタン目線でまとめ、ボタンをクリックした時にどの順序でどの処理をしていくのか を整理したのが図3-2になります。
プログラムの流れ
図3-2

各ボタンを左端列に並べ、①②・・・は処理する順番を表します。またプロシージャ名の赤字は標準モジュール、黒字はフォームモジュールに置いてあります。
検索から更新・削除に矢印が出ているのは、「検索をした結果(リストボックス)」のデータをクリックし、クリックしたデータに対して「更新・削除の処理を行う」ことを示しています。

4.標準モジュール(Module1)

今回のプログラムコードは、「標準モジュール」と「フォームモジュール」に分けて置いています。
標準モジュール側には、システム起動の為のプロシージャ、及びシート上のデータを処理するプロシージャ等を置いています。一方フォームモジュール側には、フォーム上に関わる処理のプロシージャを置いています。
今回使用するUserFormは1つ(サンプルファイルにあるUserForm2は、図4-49の「短時間メッセージ」紹介用)であり、処理の指示は全てUserForm側から行っていますので、起動プロシージャ以外の「シート上のデータを処理するプロシージャ」をフォームモジュールに記載することも可能です(その方がPrivateでプロシージャを宣言できるので、正しいかもしれません)。
しかし、システムを拡張してUserFormが増えた時を考え、「シート上のデータ処理のコードは標準モジュール」に置くことにしました。

また今回システムでは、データをテーブル(ListObjectオブジェクト)として管理しますので、例えばデータ最終行を求めるには、従来では「Cells(Rows.Count,1).End(xlUp)」などとする所を、テーブルでは「ListObjects(〇〇).Range.Row + ListObjects(〇〇).ListRows.Count」のように「テーブル全体・見出し部・データ部・データ行・データ列の単位」にアクセスし、「それぞれを1つのオブジェクトとして扱う」ことで、特定の位置・データに辿り着きます。
また、テーブルの最下行の1つ下の行にデータを追加することで、自動的にテーブル範囲が広がることも特徴です。
ListObjectの基本的な操作等については「よりみち」で説明します。

4-1.宣言部

標準モジュールの宣言部(プロシージャよりも前の部分)では、システム全体で使用する定数・変数の宣言、及びExcelには無い「一定時間で閉じるメッセージボックス」機能を使用するためのWindows APIへの参照宣言をします。
  1. '========== ⇩(1) 宣言部 ============
  2. Public Const ShName As String = "sheet1"
  3. Public Const TableName As String = "DVDmgt"
  4. Public Const SearchGenre As String = "J1"
  5. Public SearchCond(1 To 7) As Variant
  6. Public Glist(1 To 12) As String
  7. #If Win64 Then
  8.  Private Declare PtrSafe Function MessageBoxTimeoutA Lib "User32" ( _
  9.   ByVal Hwnd As LongPtr, ByVal lpText As String, ByVal lpCaption As String, _
  10.   ByVal uType As VbMsgBoxStyle, ByVal wLanguageID As LongPtr, ByVal dwMilliseconds As LongPtr) As LongPtr
  11. #Else
  12.  Private Declare Function MessageBoxTimeoutA Lib "User32" ( _
  13.   ByVal Hwnd As Long, ByVal lpText As String, ByVal lpCaption As String, _
  14.   ByVal uType As VbMsgBoxStyle, ByVal wLanguageID As Long, ByVal dwMilliseconds As Long) As Long
  15. #End If
図4-1

2行目「Public Const ShName As String = "sheet1"」は、定数ShNameに対して「Sheet1」を設定しています。このExcelファイル(ThisWorkbook)の「データを置いているワークシート位置」を文字列で示します。Sheetオブジェクトとして宣言すれば、以下のプログラムコードがスッキリするのですが、今回はWorkbook_Openなどを使用せずに文字列設定としました。

3行目「Public Const TableName As String = "DVDmgt"」は、データであるテーブルの名前です。今回は「DVDをmanagement(mgt)するテーブル」という意味で命名しています。

4行目「Public Const SearchGenre As String = "J1"」は、「ジャンルで絞り込み」を行うために用意した「比較用ジャンル値」を置く場所(今回はJ1セル)を示しています。今回ジャンルは12種で、それぞれON-OFFがありますので「12ビットの数値(最大で4095)」でジャンルを表現し、各データ行に保存しています。
そのジャンル値を絞り込むには、ワークシート関数のBITANDを使用し「BITAND(各行のジャンル値 , 比較用ジャンル値)」(VBAなら「各行のジャンル値 And 比較用ジャンル値」)で1以上になるものを選別すれば良いことになります。今回テーブル内では6列目にその計算式を貼り付け、基準となる「比較用ジャンル値」を「検索の都度、SearchGenreが示すセル位置に貼り付ける」ことにしました。

7行目「Public SearchCond(1 To 7) As Variant」は、あたかも下段リストボックスで検索結果が保存されているかのように見せかけるために、「ユーザーが検索した条件」を一時保存しておくための変数です。今回の検索する種類は6種ですが、列の番号に合わせるため、計算式列(6列目)の分は使用しない要素になりますが7要素の配列としています。
また、新規入力時にも入力項目をそのまま残すために、このSearchCond変数を使用しています。

8行目「Public Glist(1 To 12) As String」はジャンルのリストです。フォーム上のCheckBoxの文字列を書き込む際に使用しています。

10~18行目の「#If Win64 Then ・・・ #Else ・・・ #End If」では、Windows APIへの参照宣言をしています。今回仕様するWindows APIは「MessageBoxTimeoutA関数」で、「一定時間後に自動的に閉じるメッセージボックス」です。
Excelのバージョンで32ビット版と64ビット版とでは、参照宣言の内容が異なるため、条件付きコンパイル(#If Win64 Then)を使用してバージョンで分岐させ、マクロのコードをバージョン毎に変更しなくても済むようにしています。
ちなみに、32ビット版と64ビット版とで異なる部分は、64ビット版には「PtrSafe」を付けるところと、Long型を「LongPtr」とするところのみです。

4-2.システム起動部

システムの起動は、図4-2の「DVDmgtStart」プロシージャから行います。アドイン登録する場合の起動ボタン、及びシート上に起動ボタンには、このDVDmgtStartプロシージャをマクロ登録して下さい。
  1. '========== ⇩(2) システム起動 ============
  2. Public Sub DVDmgtStart()
  3.  Call MakeGlist
  4.  UserForm1.Show
  5. End Sub
図4-2

22行目「Call MakeGlist」では、図4-3を呼び出し、ジャンル種の文字列を変数Glistに代入しています。この作業は、フォーム作成時に各CheckBoxのCaptionにジャンル種を書き込んでしまえば不要になりますが、今回のDVD管理だけでは無く、他にも応用できるのでは と考えて、マクロ側から文字列設定をするようにしています。

23行目「UserForm1.Show」では、UserForm1(操作ダイアログ)を起動しています。今回はワークシートを直接操作しないシステムにしましたので、引数を付けず既定のモーダル(vbModal 値=1)で起動しています。

図4-2の22行目から呼び出されるのが、ジャンル種の文字列を変数Glistに代入するプロシージャ図4-3です。
このGlist変数に代入した文字列は、操作ダイアログのCheckBoxのCaption設定に使用しています。
  1. '========== ⇩(3) ジャンル種のデータ代入 ============
  2. Private Sub MakeGlist()
  3.  Glist(1) = "ニュース/報道"
  4.  Glist(2) = "スポーツ"
  5.  Glist(3) = "情報/ワイドショー"
  6.  Glist(4) = "ドラマ"
  7.  Glist(5) = "音楽"
  8.  Glist(6) = "バラエティ"
  9.  Glist(7) = "映画"
  10.  Glist(8) = "アニメ/特撮"
  11.  Glist(9) = "ドキュメンタリー/教養"
  12.  Glist(10) = "劇場/公演"
  13.  Glist(11) = "趣味/教育"
  14.  Glist(12) = "福祉"
  15. End Sub
図4-3

今回12種のジャンルにしましたが、これは適当に分類した訳ではなく「デジタル放送に使用する番組配列情報標準規格」の大分類を使用しています。但し規格上では、更に「予備・予備・拡張・その他」が続いており全16項目になっています。また番号はゼロから始まっていますので、今回は改変して使用していることになります。

4-3.テーブルの並べ替え

まずデータの並べ替えを行うのが図4-4です。これは、図5-22の476行目、図5-41の687行目から呼び出され、「最新のデータを一番上に表示」されるために、降順で並べ替えを行っています。
  1. '========== ⇩(4) ソート ============
  2. Public Sub TableSort(key As String)
  3.  With ThisWorkbook.Sheets(ShName).ListObjects(TableName).Sort
  4.   With .SortFields
  5.    .Clear
  6.    .Add key:=ThisWorkbook.Sheets(ShName).ListObjects(TableName).ListColumns(key).Range, _
  7.       SortOn:=xlSortOnValues, _
  8.       Order:=xlDescending, _
  9.       DataOption:=xlSortNormal
  10.   End With
  11.   .Header = xlYes
  12.   .Orientation = xlTopToBottom
  13.   .Apply
  14.  End With
  15. End Sub
図4-4

44行目「With ThisWorkbook.Sheets(ShName).ListObjects(TableName).Sort」では、並べ替えの対象を設定しています。
今回の並べ替えの対象は、図4-6左の「ThisWorkbook.Sheets(ShName).ListObjects(TableName)」です。

44行目の最後の「Sort」はオブジェクトです。このSortオブジェクトには設定可能なプロパティが、図4-5のように5つあります。
Sortの設定可能プロパティ
プロパティ内容
Header先頭行を見出しとするか
MatchCase大文字小文字の区別
Orientation並べ替えの方向
SortFields並べ替えのキー値など
SortMethodふりがなを使うか
図4-5

この中の「SortFields」を45~51行目で設定します。45行目「With .SortFields」でコードを簡略化しています。
SortFieldsは、手動で言うと図4-6の様にテーブル内のどこかのセルを選択しておき、「データ」タブ→「並べ替えとフィルター」グループ→「並べ替え」ボタンをクリックすると「並べ替えダイアログ」が表示されます。SortFieldsは、この「並べ替えダイアログ」の「並べ替えレベル」に相当します。
並べ替えの対象と並べ替えレベル
図4-6

この「並べ替えレベル」に対し、まず46行目「.Clear」メソッドで、並べ替えレベルを初期化します。以前実行した並べ替えの条件をクリアしておかないと、狙いの通りの並べ替えが出来ないので必須です。

次に47~50行目の「.Add」メソッドで、新たな並べ替えレベルを作成します。Addには以下のパラメータを指定します。
Sort.Addのパラメータ
名前内容
KeyRange並べ替えの列(必須)
SortOnVariant並べ替えのキー
OrderVariant並べ替え順序
CustomOrderVariantユーザー指定の並べ替え順序
DataOptionVariantデータオプション
図4-7

まず「Key」は必須で、今回はプロシージャが引数で受け取った「列位置Key」を指定します。テーブルでの列の指定方法は「よりみち」で詳細説明しますが、「key:=ThisWorkbook.Sheets(ShName).ListObjects(TableName).ListColumns(key).Range」となります。 次に「SortOn」は並べ替えのキーで、図4-6の並べ替えダイアログでは中央の「並べ替えのキー」に対応します。「キー」という言葉が使われているので「Key」との区別が分かりにくいのですが、指定する値は図4-8となり、通常は「値(xlSortOnValues)」で良さそうです。
今回も「SortOn:=xlSortOnValues」を設定していますが、既定値ですので設定を省略してもOKです。
SortOn
定数内容
xlSortOnValues0値(既定値)
xlSortOnCellColor1セルの色
xlSortOnFontColor2フォントの色
xlSortOnIcon3アイコン
図4-8

「Order」では並べ替え順序を設定します。図4-6の並べ替えダイアログでは右側の「順序」に対応します。値としては、図4-9の値を設定し、今回は「新しいものを上に表示」するために「Order:=xlDescending」としました。
Order
定数内容
xlAscending1昇順で並べ替え(既定値)
xlDescending2降順で並べ替え
図4-9

「CustomOrder」は、ユーザー指定の並べ替え順序を指定します。「CustomOrder:="第1四半期,第2四半期,第3四半期,第4四半期"」のように、カンマ区切りの文字列で指定します。または、ユーザー設定リストにリストを登録(既設定のリストを使用しても可)した後、その登録位置を数値で指定することも可能です。
図4-6の並べ替えダイアログでは右側の「順序」のドロップダウンリストの一番下の「ユーザー設定リスト」から指定するのと同等です。今回は、CustomOrder指定は行いませんでした。

最後の「DataOption」はデータオプションで、テキストを並べ替える方法を指定します。図4-10の値を設定します。
DataOption
定数内容
xlSortNormal0数値とテキストを別々に並べ替え(既定値)
xlSortTextAsNumbers1テキストを数値データとして並べ替え
図4-10

「xlSortTextAsNumbers」は「文字列を数字として」扱いますので、例えば「文字列にした数値(先頭にアポストロフィを付いたもの)」が1から10まで並んでいるテーブルでは、「文字列を数値」にしますので、ちゃんと「1から10の順序」で並びます。
一方「xlSortNormal」を指定した場合には「文字列にした数値」は文字列ですので、まず先頭文字列で判断し「1,10,2,3,4,5,6,7,8,9」という順序で並べます。
今回はテーブルの並べ替えを行うのは「DVD番号」のみで、「DVD番号」は数値を文字列として書き込んではいませんので、既定値の「DataOption:=xlSortNormal」としています。もちろん既定値ですので設定を省略してもOKです。

ここまででSortField(図4-6の1行分の条件)への設定が完了です。複数の条件を設定(例えば1列目を第一優先でソートし、2列目を第二優先ソートする等)するには、更にAddメソッドを実行すれば良いのですが、今回は「第一優先のみ」としました。

図4-5のSortオブジェクトの設定可能プロパティの内、次のHeaderを52行目「.Header = xlYes」で設定しています。
Headerプロパティは「先頭行を見出しとして使用するかどうか」であり、図4-11の値を設定します。
Header
定数内容
xlGuess0Excel が自動的に設定
xlYes1見出しとして使用(テーブルの場合の既定値)
xlNo2見出しとして使用しない(既定値)
図4-11

Headerに設定する値は、通常のSortの場合は「xlNo」が既定値になるのですが、ListObjectを使用する場合は「xlYes」が既定値となり、xlNoは選択できません。これは、手動で並べ替えを行う際にも、図4-12のように「先頭行をデータの見出しとして使用する」のチェックを外すことができないことからも分かります。
並べ替えオプションの設定
図4-12

実際に「Header = xlNo」を設定しようとすると実行時エラーとなります(xlGuessは使えるようです)。
逆に「Header = xlYes」が既定値ですから設定しなくてもOKですが、「マクロ記録」で取得した並べ替えのコードには、このHeaderと後で紹介するOrientationだけは記録されているのでそのまま載せています。なお既定値のプロパティであっても、重要なものは明示的に記した方が良いと思います。

次の「MatchCase」は、大文字と小文字を区別するか否かの設定です。設定値は図4-13の値です。
特に区別する必要もないため、既定値のままとし、コードを省略しています。
MatchCase
定数内容
-True大文字と小文字を区別する
-False大文字と小文字を区別しない(既定値)
図4-13

次の「Orientation」は、並べ替えの方向の設定です。設定値は図4-14の値です。
Orientation
定数内容
xlSortColumns(又はxlTopToBottom)1行単位で並べ替え(既定値)
xlSortRows(又はxlLeftToRight)2列単位で並べ替え
図4-14

このOrientationも、図4-12のオプション「方向」のところで分かる様に「列単位(xlSortRows(又はxlLeftToRight))」は選択できず、指定してしまうとエラーが発生します。よって、指定できるのは「行単位」のみですので、指定しなくてもOKです。
今回は、52行目「.Orientation = xlTopToBottom」と、マクロ記録のままをコード化しています。

最後の「SortMethod」は、日本語を「ふりがなで並べるか、文字コードで並べるか」の設定です。設定値は図4-15です。
SortMethod
定数内容
xlPinYin1日本語をふりがなで並べる(既定値)
xlStroke2日本語を文字コードで並べる
図4-15

例えば、漢字で「青」「赤」「秋」は、「あお」「あか」「あき」と「訓読みで入力する」と「訓読みのふりがな」が振られます(関数PHONETICを使用)。
一方、文字コードは図4-16のように「訓読みの順番とは異なる」ことが分かります。これは文字コードは音読みの順番で振っているためのようです(全てがこの理論で並ぶ訳では無いようです)。
漢字訓読み音読み文字コード(SJIS)
あお ①セイ ②0x90c2 ②
あか ②セキ ③0x90d4 ③
あき ③シュウ ①0x8f48 ①
図4-16

ですので、訓読みで漢字を入力している場合に「SortMethod = xlPinYin」で並べ替えれば「青」→「赤」→「秋」の順番で並びますし、「SortMethod = xlStroke」で並べ替えれば「秋」→「青」→「赤」の順番で並びます。

但し「青」「赤」「秋」という漢字を「セイ」「セキ」「シュウ」とタイプして入力した場合には、「SortMethod = xlPinYin」で並べ替えても「秋」→「青」→「赤」の順番で並ぶのです。
また「マクロ側からセルに文字を入力する(=セル値に漢字を設定する)」と、ふりがな(関数PHONETIC)の戻り値が「漢字そのもの」になってしまいます。この状態で「SortMethod = xlPinYin」で並べ替えを行っても「文字コードの順番で並ぶ」結果となります。つまり「音読みで入力した」事になるようです。

今回システムでは、フォームを介して文字列をテーブルに書き込んでいるため、xlPinYinで設定しても結果は「文字コード並び」になってしまうので、設定を省略しました。

以上で、Sortオブジェクトに対するプロパティの設定は完了です。
設定が出来たところで54行目のSortオブジェクトの「Applyメソッド」で並べ替えを実行します。

寄り道
図4-4の47行目で出てきた「ListObjects(TableName).ListColumns(key).Range」のようなListObjectオブジェクトの範囲指定は、従来のセルの指定方法とは勝手が異なりますので、ここで一通り説明します。

テーブルの作成と操作

まず既存の表(例:図4-17のA1~G8セルの範囲)を手動でテーブルにするには、データ範囲の中の1セル(図4-17ではA1セル)を選択①し、リボンの「挿入」タブ→「テーブル」グループ→「テーブル」ボタンをクリック②します。
すると、「テーブルの作成」ダイアログが表示されますので、データの範囲と先頭行を見出しとするか否かのチェックを確認し、OKボタン③をクリックします。
テーブルの手動での作成
図4-17

①~③の操作により図4-18のように、表はテーブル(ListObjectオブジェクト)として扱うことができるようになります。
テーブルの特徴
図4-18

テーブルは、外見上は見出し行(図4-18の1行目)が濃い青の背景色+白色文字で、データ行はまだらの背景色が設定されています。
また各見出しにはフィルター(下向き三角形の印)が自動的に設定され、クリックで表示されるダイアログ(図4-18の右側)を使って「並べ替え」や「絞り込み」が出来ます。

テーブル範囲の右下角のセルには「テーブルの角」であるマークがあります。テーブル範囲に接触する行・列のセルに値を入力すると、テーブルの範囲は自動的に広がります。
逆に、テーブル範囲の最下段の「データを削除」しても自動的にテーブル範囲が狭まることはありません。データ行を削除し、正しいテーブル範囲を確保するには、図4-19のように「削除したいデータ行のセルで右クリックし、メニューの中から「削除」→「テーブルの行」を選択するとデータ行が削除され、テーブルの範囲も行方向に縮小します。ちょうど「削除+上方向にシフト」を選んだような形になります。
テーブルの全範囲
図4-19

一方マクロ操作で「EntireRow.Delete(行単位で削除)」を使っでもテーブル範囲は縮小できますが、1つのシートに複数のテーブルを置いてある場合には、他のテーブルに影響を与えてしまうため、「EntireRow.Delete」を使用するには注意が必要です。

テーブルの範囲の指定法

テーブルの「見出し+データ」の部分を指定するには「Range」を使用します。従ってテーブル全体の範囲を示すには、図4-20のように「ListObjects(テーブル名).Range」となります。(以降の説明ではテーブル名をDVDmgtとしています)
テーブルの全範囲
図4-20

一方、テーブルの「データのみ」の部分を指定するには、Rangeの代わりに「DataBodyRange」を使用します。テーブルの全データ範囲を示すには、図4-21のように「ListObjects(テーブル名).DataBodyRange」となります。
テーブルのデータ範囲
図4-21

テーブルの「見出しのみ」の部分を指定するには「HeaderRowRange」を使用します。テーブルの見出し範囲全てを示すには、図4-22のように「ListObjects(テーブル名).HeaderRowRange」となります。
テーブルの見出し範囲
図4-22

テーブルの列・行の指定法

テーブルの列の集合体は「ListColumns」コレクションで表します。その内の1つ(特定の列)を示すには、ListColumnsコレクションの引数に「見出しの文字列」または「テーブルの左側からの列位置」を指定します。
簡単に言えば、図4-23のExcelシートのコレクションとオブジェクトの関係と同様に、ListColumnsの引数に「見出しの文字列」または「テーブルの左側からの列位置」を指定すれば、列を表すオブジェクトになります。
コレクションとオブジェクトの関係
図4-23

例えば図4-24のように、DvdNo列(=テーブルとしては1列目)を指定するには「ListObjects(テーブル名).ListColumns("DvdNo").Range」または「ListObjects(テーブル名).ListColumns(1).Range」となります。
テーブルの列全体
図4-24

ここで、範囲名に「Range」を使用していますので、図4-20と同様に「見出し+データ」が範囲に含まれます。

これを「データ部分のみ」の「列範囲」にするには、図4-21で使用した「DataBodyRange」に置き換え、図4-25の様に「ListObjects(テーブル名).ListColumns("DvdNo").DataBodyRange」または「ListObjects(テーブル名).ListColumns(1).DataBodyRange」とします。
テーブルの列のデータ部分
図4-25

一方、行を指定する場合は「行には行見出しが無い」ために「データの先頭からの行位置」を使って、図4-26のように「ListObjects(テーブル名).ListRows(3).Range」とします。
テーブルのデータ行
図4-26

この際、ListRowオブジェクトに対してのプロパティの中に「DataBodyRange」は存在しません。「Range」でセル範囲を指定します。

4-4.テーブルの絞り込み実行と解除

次に、各列の絞り込みを行うプロシージャが図4-27です。引数として以下の3つを受け取ります。
 ・第一引数「key」:絞り込みを行う「列番号」
 ・第二引数「Word」:絞り込みのテキスト
 ・第三引数「LK」:第二引数の絞り込みテキストを部分一致検索にする場合は「*」を指定(LKはlikeの略)
  1. '========== ⇩(5) 絞り込み実施 ============
  2. Public Sub TableFilter(key As Integer, Word As String, Optional LK As String = "")
  3.  With ThisWorkbook.Sheets(ShName).ListObjects(TableName).Range
  4.   If Not Word = "" Then
  5.    .AutoFilter Field:=key, Criteria1:=LK & Word & LK
  6.   End If
  7.  End With
  8. End Sub
図4-27

61行目「With ThisWorkbook.Sheets(ShName).ListObjects(TableName).Range」で、絞り込みの対象を「テーブル全体」とするため、「.Range」を使用しています。
しかしこの絞り込み対象は、見出し行を除く「.DataBodyRange」を使って、対象をデータ部分のみに変更しても問題ありません。それどころか、テーブルの中の1セル(例えば「Sheets(ShName).Range("A1")」でもOKです。
しかしRange("A1")としたのでは、プログラムとして何を対象に絞り込んでいるのかが不明瞭になってしまうので、今回はテーブルのRangeを使っています。

今回システムでは操作ダイアログの各項目が空欄だった時、「データが空のものを選択する」という意味では無く、「その項目では絞り込みを実行しない」を意味することにしています。そのため、62行目「If Not Word = "" Then」で「空欄の時には63行目の絞り込みを実行しない」ようにしています。
もし、ダイアログの空欄を「空のものを選択する」と言う意味に用いるならば、62行目・64行目は削除して下さい。

63行目「.AutoFilter Field:=key, Criteria1:=LK & Word & LK」は、AutoFilterメソッドの第一引数Keyの「Field(列位置)」に対して、絞り込みを行っています。絞り込みの内容は「Criteria1:=」の後ろ側に記載します。
ここでは、プロシージャの第二引数Wordの前後に第三引数のLKをくっつけています。

呼び出す側が、例えば「Call TableFilter(2,"相棒","*")」となっていれば、63行目は「.AutoFilter Field:=2, Criteria1:="*相棒*"」となり、「2列目を『相棒』という文字を含んだ項目で絞り込む(部分一致)」という意味になります。
一方、TableFilterプロシージャの第三引数にはOptionalが付いており省略可ですので、「Call TableFilter(2,"相棒")」とした場合は「.AutoFilter Field:=2, Criteria1:="相棒"」となり、「2列目を、単に『相棒』となっている項目のみで絞り込む(完全一致)」という意味になります。

つまりプロシージャの第三引数は「部分一致検索」のための「*(アスタリスク)」になるのですが、この有無が重要な意味になるのは数値を絞り込む時です。
例えば、1列目に数値が文字列(先頭に'(アポストロフィ)が付いている等)として入っている場合に「Call TableFilter(1,"1","*")」とすると「.AutoFilter Field:=1, Criteria1:="*1*"」となり、絞り込まれるものは「1」「11」「12」・・「21」・・「100」・・と1の付くものが全て抽出されます。
一方、1列目が数値そのものの列の場合には、「.AutoFilter Field:=1, Criteria1:="*1*"」では数値は1つも抽出されません。このことから、今回の63行目の式では「数値列に部分一致検索は使ってはいけない」ことが分かります。

次に「絞り込みの解除」を行うプロシージャが図4-28です。1列ずつではなく、全列をまとめて解除する仕様です。
  1. '========== ⇩(6) 絞り込み解除 ============
  2. Public Sub TableFilterOFF()
  3.  Dim i As Long    '←カウンタ変数(列の数)
  4.  With ThisWorkbook.Sheets(ShName).ListObjects(TableName).Range
  5.   For i = 1 To LastCol
  6.    .AutoFilter Field:=i
  7.   Next i
  8.  End With
  9. End Sub
図4-28

73行目「For i = 1 To LastCol」ではカウンタ変数iをテーブルの列数分だけ回しています。この中の「LastCol(図4-29)」はテーブルの列数を戻してくれる関数です。
74行目「.AutoFilter Field:=i」で、1列目から順番に絞り込みを解除しています。「AutoFilterメソッド」で「Fieldパラメータ」のみを指定すると、その列の絞り込みを解除することになります(元々絞り込んでいない列については、変化は起こりません)。

4-5.テーブルのサイズ取得

テーブルの列数を取得する関数プロシージャが図4-29です。
  1. '========== ⇩(7) テーブル列数取得 ============
  2. Public Function LastCol() As Long
  3.  With ThisWorkbook.Sheets(ShName).ListObjects(TableName)
  4.   LastCol = .HeaderRowRange.Count
  5.  End With
  6. End Function
図4-29

82行目「LastCol = .HeaderRowRange.Count」で、テーブルの列数を取得し、関数プロシージャの戻り値にしています。
ここで「HeaderRowRange」は、図4-22の「見出し部全体」を示します。見出しは1行ですので、そのセル数(Count)が列数に相当することになります。

テーブルのデータ行数を取得する関数プロシージャが図4-30です。
  1. '========== ⇩(8) データ行数取得 ============
  2. Public Function LastLrow() As Long
  3.  With ThisWorkbook.Sheets(ShName).ListObjects(TableName)
  4.   LastLrow = .ListRows.Count
  5.  End With
  6. End Function
図4-30

89行目「LastLrow = .ListRows.Count」で、データ行数を取得し、関数プロシージャの戻り値にしています。
これは「ListRows」が、図4-26の各データ行の集合体であることを利用しています。
また、図4-21の「DataBodyRange(データ範囲全体)」を利用して、「.DataBodyRange.Rows.Count」としても同じことになりそうです。

しかし図4-31のように、データが1つも無い状態もあります。テーブルを作ったばかりの時とか、データを全て削除してしまった状態です。
データが無いテーブル
図4-31

図4-31を見ると、2行目が薄い青色の背景色になっているので「データ行がある」「DataBodyRangeが存在する」ように錯覚しますが、この薄い青色の行は「InsertRowRange」であり「DataBodyRange」ではありません。ですのでInsertRowRangeに関して言えば「ListObjects(〇〇).InsertRowRange.Address = $A$2:$G$2」となりますが、実データ部分については
 ・ListObjects(〇〇).ListRows.Count = 0
 ・ListObjects(〇〇).DataBodyRange Is Nothing = True
となります。
ですので、89行目に「.DataBodyRange.Rows.Count」を使ってしまうと、データが無い状態ではエラーが発生してしまいますので、今回89行目には「.ListRows.Count」を使用しました。

なおデータの絞り込みを行い、見かけ上の行数が少なくても、「データが無くなった訳ではないので、得られるデータ行数は同じ」となります。絞り込み後の見かけの行を取得するには「SpecialCells(xlCellTypeVisible)」などを使用する必要があります。

4-6.列内の最大値を取得

テーブルの数値列で、最大値を取得するのが図4-32です。引数として「列の見出し名(key)」を受け取ります。
今回システムで新規にデータ登録する際、他のDvd番号と重複しない番号を割り当てるために、この関数プロシージャでDvdNo列の最大値を求め、その値に「+1」した番号を新DVD番号としています。
  1. '========== ⇩(9) 列内データの最大値取得 ============
  2. Public Function TableMax(key As String) As Long
  3.  With ThisWorkbook.Sheets(ShName).ListObjects(TableName)
  4.   If Not .ListRows.Count = 0 Then
  5.    TableMax = WorksheetFunction.Max(.ListColumns(key).DataBodyRange)
  6.   End If
  7.  End With
  8. End Function
図4-32

98行目「TableMax = WorksheetFunction.Max(.ListColumns(key).DataBodyRange)」で、ワークシート関数Maxを使い、引数keyで指定した列のデータの最大値を計算し、TableMax関数プロシージャの戻り値にしています。
但し、データが無い(DataBodyRange is Nothing = True)場合にはエラーが出ますので、97行目「If Not .ListRows.Count = 0 Then」で「データが存在する時」のみ、Maxの計算をしています。
もしデータが存在しない場合は、98行目の計算をしないため、関数プロシージャの戻り値(94行目のLong型)の初期値である「ゼロ」が戻ることになります。
また、データ行は存在するが「データが入っていない」場合や、文字列の列の場合は、Maxの計算結果は「ゼロ」となります。

4-7.データの追加・更新と削除

テーブルにデータを新規追加、または既存データを上書き更新するのが図4-33です。
第一引数(Lrow:ListRange Rowの略)として、データを入れる「テーブルの(見出し行を含めない)データ行位置」を受取ります。また、入れるデータは第二引数(Data)で配列の形で受け取ります。
Lrowの値によりテーブルの何行目にデータを入れるかが決まり、その値が「現テーブルの行数+1」であれば新規追加となり、「現テーブルの行数以下」であれば、上書き更新となります。
  1. '========== ⇩(10) データの追加・更新 ============
  2. Public Sub TableDataIn(Lrow As Long, Data() As Variant)
  3.  Dim i As Long    '←カウンタ変数(列の数)
  4.  With ThisWorkbook.Sheets(ShName).ListObjects(TableName)
  5.   For i = 1 To LastCol
  6.    .ListColumns(i).Range(Lrow + 1) = Data(i)
  7.   Next i
  8.  End With
  9. End Sub
図4-33

109行目「For i = 1 To LastCol」で、カウンタ変数iをテーブルの列数だけ回しながら処理をします。「LastCol」は図4-29のLastCol関数プロシージャで、テーブルの列数を取得しています。
110行目「.ListColumns(i).Range(Lrow + 1) = Data(i)」では、書き込み位置の内、列位置を「ListColumns(i)」で1つずつ右側に移動させながら、指定行位置「Range(Lrow + 1)」にデータを書きこんでいきます。

まず書き込み位置の他の指定方法としては、「.ListColumns(i).DataBodyRange(Lrow)」や「.Listrows(Lrow).range(i)」などが考えられます。しかし「もしデータが1行も無い」場合には、エラーが発生してしまいます。
ですので、今回はRangeを使って「見出しも含めた縦方向位置」で指示するようにしました。テーブルには必ず見出しが1行存在しますので、+1をし「.ListColumns(i).Range(Lrow + 1)」としました。

次に、第二引数として受け取った「貼り付けデータ配列」ですが、図4-34のように、今回テーブルの1列目から順にデータを代入した配列になっています。配列のインデックスもテーブルの列位置と合わせてあります。
ですので、110行目の右辺の「 = Data(i)」で、各列にデータが書き込まれることになります。
貼り付けデータの内容
図4-34

別な貼り付け方法として、109~111行目のFor~Nextの代わりに「.ListColumns(1).Range(Lrow + 1).Resize(1, UBound(Data, 1)) = Data」で、一発で貼り付ける方法を試しましたが、「数式を貼り付ける(6列目)」所でエラーが出ました。
エラーの出た貼り付け数式は「"=BITAND([@Genre],$J$1)"」なのですが、どうも[@Genre]の部分が引っ掛かるようです。今のところ良い考えが思い浮かばないため、For~Nextで1つずつ貼り付けています。

テーブルのデータを1行単位で削除するのが図4-35です。
引数(Lrow:ListRange Rowの略)として、削除データの「テーブルの(見出し行を含めない)データ行位置」を受取ります。
  1. '========== ⇩(11) データの削除 ============
  2. Public Sub TableDataDel(Lrow As Long)
  3.  With ThisWorkbook.Sheets(ShName).ListObjects(TableName)
  4.   .ListRows(Lrow).Delete
  5.  End With
  6. End Sub
図4-35

119行目「.ListRows(Lrow).Delete」では「Lrow行目のデータを削除」しており、これは図4-19で説明した手動削除と同等の方法です。
一方、テーブルを使用していない場合に良く使われる削除方法は「EntireRow」を使った行全体を削除する方法で、これを使うとすれば「.ListRows(Lrow).Range.EntireRow.Delete」になります。この方法は、1つのシートにテーブルが1つのみであれば問題はありませんが、複数テーブルがあると削除時に別なテーブルのデータを削除してしまう可能性があるため、注意が必要です。
今回は、複数のテーブルが存在しても問題無いようにするため、119行目のコードにしました。

4-8.可視セルデータの配列化

本システムでは、検索時および更新データ・削除データの行位置の取得には、テーブルの絞り込み機能を利用し、絞り込んだデータを配列の形で取得します。その配列データを加工してリスト化したり、更新・削除の行位置を指定したりしています。
その「絞り込んだデータを配列化」するのが、図4-36のSearchList関数プロシージャです。
  1. '========== ⇩(12) 可視セルデータの配列化 ============
  2. Public Function SearchList() As Variant
  3.  Dim buf As Variant      '←戻し値の仮配列
  4.  Dim HeaderRow As Long    '←Headerの行位置
  5.  Dim cnt As Long       '←カウンタ変数(配列の行数)
  6.  Dim i As Long        '←カウンタ変数(エリアの数)
  7.  Dim j As Long        '←カウンタ変数(列の数)
  8.  Dim k As Long        '←カウンタ変数(エリア内の行数)
  9.  With ThisWorkbook.Sheets(ShName).ListObjects(TableName)
  10.   HeaderRow = .HeaderRowRange.row
  11.   On Error Resume Next
  12.   With .DataBodyRange.SpecialCells(xlCellTypeVisible)
  13.    ReDim buf(1 To .Count / LastCol, 0 To LastCol)
  14.    cnt = 1
  15.    For i = 1 To .Areas.Count
  16.     For k = 1 To .Areas(i).Rows.Count
  17.      buf(cnt, 0) = .Areas(i).Cells(k, 1).row - HeaderRow
  18.      For j = 1 To UBound(buf, 2)
  19.       buf(cnt, j) = .Areas(i).Cells(k, j).Value
  20.      Next j
  21.      cnt = cnt + 1
  22.     Next k
  23.    Next i
  24.   End With
  25.   On Error GoTo 0
  26.  End With
  27.  SearchList = buf
  28. End Function
図4-36

まず、133行目「HeaderRow = .HeaderRowRange.row」で、テーブルの見出し行の行絶対位置を取得します。この変数HeaderRowは、143行目で「データ行絶対位置を相対位置に変換するための補正」として使用されるので、あらかじめ変数に代入しておき定数的に使用するものです。
137行目「With .DataBodyRange.SpecialCells(xlCellTypeVisible)」では、テーブルを絞り込んだ後の「可視セル」範囲に基準を合わせています。
しかし、絞り込んだ時に「可視データが1つも無くなってしまった」ときにはエラーが発生してしまいます。ですので135行目「On Error Resume Next」で、エラーが発生した時には137~151行目までを無視し、155行目「SearchList = buf」で関数の戻り値をbufの初期値である「Empty」にし、プロシージャを終了します。

「可視データが1行以上存在する」場合には、138行目「ReDim buf(1 To .Count / LastCol, 0 To LastCol)」で、戻し値となる配列bufのサイズを決めます。
配列bufには、図4-37のように「絞り込んだデータ」を代入するため、サイズとしては「行方向=絞り込んだ行数/列方向=テーブルの列数」が必要です。それに加えて、絞り込んだデータの「テーブル上の行位置」をゼロ列目に代入できるよう、列サイズのインデックスは「ゼロ」からスタートさせています。
絞り込み後データと配列bufのサイズの関係
図4-37

このサイズを決める際、列方向は「LastCol(図4-29)」で得ることができます。
一方、行方向は簡単にはいきません。それは図4-38のように「絞り込んだデータが『複数のエリア』に渡っている」場合があるためです。行数を取得するには「Areasの数」を調べ、その1つ1つの「各Areaの行数」を調べて足し合わせることで、可視行数が取得できます。
絞り込み後データのArea分割
図4-38

上記の「Areasの数+各Areaの行数」の方法では無く、今回は図4-39の方法で行数を得ることにしました。
「絞り込んだデータの個数」は、「ListObjects(〇〇).DataBodyRange.SpecialCells(xlCellTypeVisible).Count」で取得できるので、それを「テーブルの列数(今回はLastColで取得)」で割って行数を求めるのです。
絞り込み後データのセル数(Count)
図4-39

これをコードにしたのが、138行目「ReDim buf(1 To .Count / LastCol, 0 To LastCol)」となります。セル総数を列数で割っている部分が「.Count / LastCol」です。また列方向がゼロ始まりなのは、ゼロ列目に「テーブルのDataBodyRangeとしての行位置」を代入するためです。

139行目「cnt = 1」は、配列bufの行位置を示しています。1行分の代入が完了した時点の147行目で、「cnt = cnt + 1」と配列bufの行位置を1つ下に移動させています。

配列bufにセルの値を代入しているのが141~149行目になります。ここでは、セルの行位置は「Areasの数+各Areaの行数」で指定するしかありません。
141行目「For i = 1 To .Areas.Count」では、エリアの数だけカウンタ変数iを回します。図4-38ではエリア数は「2」となります。
また142行目「For k = 1 To .Areas(i).Rows.Count」で、各エリア内の行数だけカウンタ変数kを回します。

これで目的の行位置に辿り着きましたので、まず143行目「buf(cnt, 0) = .Areas(i).Cells(k, 1).row - HeaderRow」で、配列bufのゼロ列目(buf(cnt, 0))に「テーブルのDataBodyRangeとしての行位置」を代入しています。
「テーブルのDataBodyRangeとしての行位置」は、直接得られそうに無いので、今回はまず「ListObjects(〇〇).DataBodyRange.SpecialCells(xlCellTypeVisible).Areas(i).Cells(k, 1).row」と1列目のセルの「絶対行位置(.row)」を取得します。そして、テーブルの見出しの絶対行位置(HeaderRow:133行目で取得した値)との差から、テーブルのデータの行相対位置(DataBodyRangeの行位置)を計算し、配列bufのゼロ列目に代入します。
絶対行位置とテーブルのデータの行相対位置
図4-40

144行目「For j = 1 To UBound(buf, 2)」は、カウンタ変数jを配列の列数(=テーブルの列数)だけ回しています。「For j = 1 To LastCol」でも良いですが、その都度「関数LastColを呼び出して計算」する必要がありますので、今回は配列の列サイズから回す回数を定めています。

145行目「buf(cnt, j) = .Areas(i).Cells(k, j).Value」では、絞り込んだ可視セルの値を配列bufに代入しています。この中で「.Cells(k, j)」は、「ListObjects(〇〇).DataBodyRange.SpecialCells(xlCellTypeVisible).Areas(i)」に対しての相対的セル位置になります。

147行目「cnt = cnt + 1」では、配列bufに代入する行位置を一つ下に移動させています。

配列bufへの値およびデータ行の行位置を代入完了したら、155行目「SearchList = buf」で関数プロシージャSearchListの戻り値に配列bufを設定します。

4-9.列内の重複しないデータ一覧の作成

操作ダイアログのコンボボックスのリストを作成するために、「指定列のデータを重複せず且つ昇順で並べた配列データ」を取得するのが図4-41です。引数として、指定列の列名(Col)を受け取ります。
  1. '========== ⇩(13) 列内の重複しないデータ一覧の作成 ============
  2. Public Function uniList(Col As String) As Variant
  3.  Dim buf1 As Variant      '←列の全データの配列
  4.  Dim buf2 As Variant      '←重複せず且つ昇順並びのデータの配列
  5.  Dim SL As Object       '←SortedListオブジェクト
  6.  Dim i As Long         '←カウンタ変数(行位置、SortedList個数)
  7.  With ThisWorkbook.Sheets(ShName).ListObjects(TableName)
  8.   If .ListRows.Count = 0 Then Exit Function
  9.   buf1 = .ListColumns(Col).Range
  10.  End With
  11.  Set SL = CreateObject("System.Collections.SortedList")
  12.  For i = 2 To UBound(buf1, 1)
  13.   If Not IsEmpty(buf1(i, 1)) And Not IsNull(buf1(i, 1)) Then
  14.    If SL.containskey(CStr(buf1(i, 1))) = False Then
  15.     SL.Add CStr(buf1(i, 1)), 0
  16.    End If
  17.   End If
  18.  Next i
  19.  If SL.Count = 0 Then Exit Function
  20.  ReDim buf2(1 To SL.Count)
  21.  For i = 1 To SL.Count
  22.   buf2(i) = SL.getkey(i - 1)
  23.  Next i
  24.  uniList = buf2
  25.  Set SL = Nothing
  26. End Function
図4-41

167行目「If .ListRows.Count = 0 Then Exit Function」では、作ったばかりのテーブルや全データを削除してしまったテーブルなど、データ行数がゼロ(.ListRows.Count = 0)の場合は、関数を抜け出します。
関数の戻り値には何も入れていませんので、Variant型の初期値であるEmptyが戻ることになります。

169行目「buf1 = .ListColumns(Col).Range」では、引数で指定された列名(Col)の「見出しを含めた全データ」を配列buf1に代入します。
ここで「見出し」を含めているのは大きな理由があります。169行目をもし「buf1 =.ListColumns(Col).DataBodyRange」とした場合を考えます。
167行目でデータが無い場合は外していますので、ここでは「1行以上のデータ」があるはずです。2行以上のデータがあれば、buf1には2つ以上のデータが入り「配列」となりますが、もし1行のみのデータだった場合、buf1には「1つのデータ」しか入らず「buf1が配列にならない」のです。
1行と2行以上でbub1が配列になるかならないか分かれてしまうと、その後の処理が非常に面倒になりますので、1行データの時でも必ず配列となるように、見出しも含めたデータを取得しています。もちろん見出しデータは使用しませんので、データ取り出しは配列の2番目以降となります。

172行目「Set SL = CreateObject("System.Collections.SortedList")」では、キーで自動的に並べ替えが実行されるSortedListオブジェクトを生成しています。

174~180行目では、配列buf1(指定列の見出しを含めた全データ)から重複を避けてSortedListデータのキーに登録していきます。
174行目「For i = 2 To UBound(buf1, 1)」で、カウンタ変数iを「2」から始めているのは、「見出し」のデータを除いた全データに対して処理をしているためです。
175行目「If Not IsEmpty(buf1(i, 1)) And Not IsNull(buf1(i, 1)) Then」は、データが「空欄(Empty)」の場合にはリスト化する意味が無いので、Notで省いています。
またSortedListデータのKeyに「Null」が入るとエラーが発生するため、「Not IsNull(buf1(i, 1))」で除去しています。但し、セル範囲の値を配列に代入するだけではNullは発生しないようなので、今回は必須では無いと思います。(データベースからデータをまとめて取り出した時にはNullが発生する場合があるので、注意が必要です。)

176行目「If SL.containskey(CStr(buf1(i, 1))) = False Then」は、処理するbuf1のデータが既にSortedListデータのキーに存在しない場合177行目の処理に進みます。
177行目「SL.Add CStr(buf1(i, 1)), 0」で、SortedListデータへの登録を行います。

176・177行目で、単に「buf1(i, 1)」とはせず「CStr関数」で文字列に変換しているのは、SortedListのキーとして「文字列と数値」の両方を登録するためです。
SortedListにキーを登録する際、例えば既に文字列が登録されている時に数値を登録しようとすると、エラーが発生します。逆も同じです。つまり「文字列と数値」を混在してキー登録は出来ません。
ですので「数字は文字列として登録」するために「CStr関数」で変更をしています。

ちなみに、文字列に変換された数値は、その後コンボボックスのリストデータになりますが、リストデータの状態では「文字列」の状態です。これが選択されて、ワークシートに貼り付けられた時に、Excelが解釈して数値として保存されます。ですので、文字列として数値を内部で扱うだけでしたら、問題はありません。(その数値を使って、計算をする時には注意が必要です)

また177行目の「SL.Add CStr(buf1(i, 1)), 0」では、値として「ゼロ」を登録しています。今回は単一列のリストを作るのが目的ですので、Keyだけが必要なので値は必要ありません。しかし値もセットで登録しないといけないので、適当なものを登録しています。「1」でも「"ABC"」でも構いません。

重複せず、また空欄も排除したSortedListデータへの登録が完了したら、182行目「If SL.Count = 0 Then Exit Function」で、SortedListデータが何個あるかを数え、ゼロ(全部空欄だった)の場合は関数プロシージャを抜け出します。
1個以上存在する場合には、183行目「ReDim buf2(1 To SL.Count)」で、変数buf2に配列サイズを与えます。

185~187行目では、SortedListデータからKeyを1つずつ取り出し、配列buf2に代入していきます。
186行目「buf2(i) = SL.getkey(i - 1)」の右辺が取り出すキーになります。その順番はゼロ始まりのため「-1」をしていますが、SortedListオブジェクトですのでデータは既に「昇順」に並んでいますので、配列buf2も昇順にデータが並ぶことになります。

最後に189行目「uniList = buf2」で、配列buf2を関数プロシージャの戻り値に設定します。ここまでの間に「Exit Function」した場合(データ行が無い、空欄しか無い)は、Variant型の初期値であるEmptyが戻ります。

4-10.短時間だけ表示するメッセージボックス等

今回システムは、操作ダイアログ上の「登録」「更新」「削除」ボタンでデータが操作できますが、ユーザーに対し「データが変わった」ことをメッセージとして伝える必要があると考え、短い時間の間だけメッセージボックスを表示することを考えました。
4種類の方法を試してみましたが、最も良さそうな「MessageBoxTimeoutA関数」を今回システムには盛り込んであります。その他の方法についても、説明します。

4-10-1.MessageBoxTimeoutAを使用

Windows APIの「MessageBoxTimeoutA関数」を使用する方法が、図4-42です。プロシージャの引数として、表示する文字列(Word)を受取ります。
  1. '========== ⇩(14) MessageBoxTimeoutAを使用 ============
  2. Public Sub myMsgBox(Word As String)
  3.  MessageBoxTimeoutA Application.Hwnd, Word, "", vbOKOnly, 0, 500
  4. End Sub
図4-42

この「MessageBoxTimeoutA」を使用するには、Windows APIの参照宣言が必要です。そのため図4-1の10~18行目で宣言を行っています。
MessageBoxTimeoutAには、参照宣言からも分かる様に6つの引数を渡します。
引数内容
HwndLongオーナーウィンドウのハンドル
lpTextString表示する文字列
lpCaptionStringメッセージボックスのタイトル文字
uTypeVbMsgBoxStyle表示するボタンとアイコン
wLanguageIDLong文字列の言語
dwMillisecondsLong自動で閉じるまでのミリ秒
図4-43

第一引数Hwndに設定する「オーナーウィンドウのハンドル」には、オーナーをExcelとするため「Application.Hwnd」を指定しています。このHwndに「0(Nullの意味)」を指定した場合には、メッセージボックスはオーナーウィンドウを持たないことになります。
オーナーを持つか持たないかの現象面の違いですが、「オーナーを持つ(Application.Hwndを設定)」場合は「Modelessでフォームを起動した感じ」になります。例えばメッセージが表示されている最中でも操作ダイアログ側にFocusを移すことは可能ですが、Excel上にはメッセージは残っています。
一方「オーナーを持たない(ゼロを設定)」場合は、「Excelとは異なるアプリのメッセージが表示されている感じ」になります。メッセージの表示中に操作ダイアログにFocusを移すと、メッセージはExcelの裏側に隠れてしまい、見失ってしまいます。
今回のシステムでは、「一瞬のメッセージ表示」なのでどちらでもOKと思いますが、メッセージがExcelの裏側に回らない「オーナーを持つ」設定にしました。

第二引数は「メッセージボックスに表示する文字列」ですので、引数のWordを設定します。
第三引数は「タイトル文字」ですが、今回は不要と考え「""(長さゼロの文字列)」としました。
第四引数は「表示するボタンとアイコン」で、今回は「OKボタン」のみが適切と考え「vbOKOnly」を設定しました。
第五引数の「文字列の言語」ですが、詳細は分かりませんでしたが「通常はゼロ」を設定するようです。他サイトの説明コードでも全てゼロ設定でした。たぶん設定値ゼロは「WindowsまたはExcelの言語と一緒」という意味なのかもしれません。

第六引数の「ミリ秒」は、色々試して0.5秒くらいが感覚的に良さそうなので「500(ミリ秒)」の設定としました。

4-10-2.Popupメソッドを使用

Windows Script Host(WSH)の1つであるPopupメソッドを使ったメッセージ表示が図4-44です。
引数としてメッセージ(Word)を受け取ります。
  1. '========== ⇩(15) Popupメソッドを使用 ============
  2. Public Sub myMsgBox2(Word As String)
  3.  Dim WSH As Object
  4.  Set WSH = CreateObject("Wscript.Shell")
  5.  WSH.Popup Word, 1
  6.  Set WSH = Nothing
  7. End Sub
図4-44

Popupメソッドでは、202行目「Set WSH = CreateObject("Wscript.Shell")」で、WSHへの参照を設定します。
Popupメソッドには4つの引数を設定します。
引数内容
TextString表示する文字列(必須)
SecondsToWaitVariant自動で閉じるまでの秒数
TitleVariantメッセージボックスのタイトル文字
TypeVariant表示するボタンとアイコン
図4-45

図4-42のMessageBoxTimeoutAと大きく異なるのは「閉じるまでの時間」で、「秒数」を秒単位で指定します。しかも「1以上の整数」である必要があり、それ以外の1未満や小数のある値(0、0.5、1.5など)を指定すると「OKボタンを押さない限り開きっぱなし」になります。
なお例えば「1.0」を指定(コードを書き込むと「1#(Double型のマーク)」に自動変換される)しても「小数点有り」と判断されるようで、自動的には閉じてくれなくなります。

また例えば「1秒」を指定しても、1秒キッカリで閉じる訳では無く、私のPCで試してみたところ図4-46のように、かなり多めの時間開いています。
設定秒数平均(n=20)範囲
11.521.49~1.58
24.854.13~7.00
35.285.13~7.84
図4-46

今回システムでは「一瞬だけメッセージを表示」したかったので、この時間はあまりにも長すぎるため、Popupメソッドは採用しませんでした。

4-10-3.ステータスバーに表示

メッセージボックスではありませんが「ステータスバー」にコメントを出す方法が、図4-47です。
  1. '========== ⇩(16) ステータスバーに表示 ============
  2. Public Sub myMsgBox3(Word As String)
  3.  Dim T As Single      '←表示開始時の時刻
  4.  Application.StatusBar = Word
  5.  T = Timer()
  6.  Do While (T + 0.5) > Timer()
  7.   DoEvents: DoEvents
  8.  Loop
  9.  Application.StatusBar = False
  10. End Sub
図4-47

表示のイメージとしては、図4-48のようにExcel左下欄外のステータスバーの表示文字列が変わる方法です。
ステータスバーでのメッセージ
図4-48

211行目「Application.StatusBar = Word」で、引数である表示文字列Wordをステータスバーに表示します。
213行目「T = Timer()」で現時刻を変数Tに代入します。Timer関数はSingle型の「1秒未満」も表してくれます。
214~216行目のDo~Loopで「0.5秒」待った後、218行目「Application.StatusBar = False」でExcel標準に戻します。

但し、ユーザーによっては「少しでも画面を広く使いたい」等の理由で、「ステータスバーを非表示」にしている場合もあります(オプションの設定等では表示・非表示の切り替えは出来ませんが、VBAからは非表示設定が可能)。その場合には、まず表示・非表示の状態を取得した後「Application.DisplayStatusBar = True」でステータスバーを表示にし、ステータスバーに文字列を表示させます。終わったら表示・非表示状態を元に戻す、と言うような処理が必要になると思います。

この方法は非常に簡単なのがメリットなのですが、操作ダイアログに視点がある状態でExcelの左下欄外の文字列が変化しても、あまり目立たないというデメリットがあります。そのため、この方法も採用しませんでした。

4-10-4.フォームを使用

メッセージ表示専用のフォームを使うのが、以下の方法になります。
まずUserForm2に、図4-49のようなフォームをデザインします。フォーム上にLabel1が1つあるだけのものです。
メッセージボックス用のフォーム
図4-49

そのフォームモジュールの「Activateイベントプロシージャ」が、図4-50です。
  1. '========== ⇩(17) フォームを使用 ============
  2. Private Sub UserForm_Activate()
  3.  Dim T As Single      '←表示開始時の時刻
  4.  T = Timer()
  5.  Do While (T + 0.5) > Timer()
  6.   DoEvents: DoEvents
  7.  Loop
  8.  Unload Me
  9. End Sub
図4-50

Activateイベントは、フォームが表示された直後に発生しますので、表示された時刻を225行目「T = Timer()」で取得し、それから0.5秒経過するまで226~228行目のDo~Loopで待つことで、メッセージボックスの表示時間を制御しています。
時間が経過したら、230行目「Unload Me」で、自分であるUserForm2を閉じます。

メッセージ用のユーザーフォームを起動するのが、図4-51です。引数として表示する文字列Wordを受け取ります。
  1. '========== ⇩(18) フォームの起動プロシージャ ============
  2. Public Sub myMsgBox4(Word As String)
  3.  UserForm2.Label1.Caption = Word
  4.  UserForm2.Show
  5. End Sub
図4-51

235行目「UserForm2.Label1.Caption = Word」で、フォーム上のLabel1に表示文字列(Word)を書き込みます。
次に236行目「UserForm2.Show」でダイアログを起動させています。
ダイアログが起動している0.5秒間は、Excelでの全ての作業が停止することになります。

今回試した中では「MessageBoxTimeoutA」と並んで「フォーム使用」が機能的には良かったです。フォームを作ったりするのが少し面倒ですが、分かり易さ・説明し易さから言えばフォームを使うのも良いと思います。

4-11.テーブルを新規作成

操作ダイアログに関係したプロシージャではありませんが、テーブルを作るコードを図4-52で紹介します。
  1. '========== ⇩(19) テーブルの作成 ============
  2. Private Sub MakeTable()
  3.  Const LTtable As String = "A1"    '←テーブルの左上セル位置
  4.  Dim TitleArray As Variant      '←列名の配列
  5.  TitleArray = Array("DvdNo", "Title", "Series", "CreateD", "Genre", "Gbit", "Storage")
  6.  Dim L As ListObject        '←作成したテーブル
  7.  
  8.  With ThisWorkbook.Worksheets(ShName)
  9.   .Range(LTtable).Resize(1, UBound(TitleArray, 1) + 1) = TitleArray
  10.   Set L = .ListObjects.Add(SourceType:=xlSrcRange, _
  11.                Source:=.Range(LTtable).CurrentRegion, _
  12.                XlListObjectHasHeaders:=xlYes)
  13.   L.Name = TableName
  14.   L.ListColumns("CreateD").Range.NumberFormatLocal = "yyyy/mm/dd"
  15.  End With
  16. End Sub
図4-52

241行目「Const LTtable As String = "A1"」は、テーブルの左上位置(LTはLeft・Topの意)を示す定数です。
243行目「TitleArray = Array("DvdNo", "Title", "Series", "CreateD", "Genre", "Gbit", "Storage")」では、テーブルの列名(=見出し文字)を配列化しています。今回は7列分を作成します。

テーブルを作成するシートは、図4-1の2行目で宣言したシート名「ShName」であるため、246行目は「With ThisWorkbook.Worksheets(ShName)」としています。
他のプロシージャ内でも「テーブルを指定」する際、「ThisWorkbook.Sheets(ShName).ListObjects(TableName)」などと「ThisWorkbook」を付けています。これはもちろん、操作するテーブルは「ThisWorkbook(=マクロが書かれているブック)」に存在する、ということを表していますが、特に「Excelアドインにシステムを登録」し「別のブック上で操作ダイアログを動かす」時には必須になります。
しかし、MakeTableプロシージャは別ブックをアクティブにした状態では起動しないと思いますので、246行目のThisWorkbookは無くてもOKと思います。

247行目「.Range(LTtable).Resize(1, UBound(TitleArray, 1) + 1) = TitleArray」で、243行目で作成した列名の一次元配列を241行目で定数宣言した場所に貼り付けます。一次元ですから横方向に貼り付けられます。

248~250行目でListObjectを追加(.Add)します。
Addメソッドには、図4-53のようにパラメータが6個あります。また、1つ目の「SourceType」パラメータは、図4-54の中から選びます
パラメータ内容
SourceTypeXlListObjectSourceTypeソースの種類(図4-54)
SourceVariant xlSrcExternal:ソースへの接続を指定する文字列配列
xlSrcRange:ソースを表すRangeオブジェクト
xlSrcXml:
xlSrcQuery:ODBC又はOLEDB接続文字列
xlSrcModel:WorkbookConnectionオブジェクト
LinkSourceBoolean外部データソースをListObjectにリンクするか否か
XlListObjectHasHeadersVariant列ラベルの有無(図4-55)
DestinationVariant移動先のセル位置
TableStyleNameStringテーブル書式の名前
図4-53

定数内容
xlSrcExternal0外部データソース
xlSrcRange1Range範囲
xlSrcXml2XML
xlSrcQuery3クエリ
xlSrcModel4PowerPivotモデル
図4-54

248行目では「SourceType」パラメータとして「xlSrcRange」を指定しています。これは247行目で列名配列を貼り付けたRange範囲を「テーブル」とするためです。(正確にはテーブルの見出しだけですが)
249行目では「Source」パラメータとして、247行目で列名を貼り付けたRange範囲を指定しています。247行目と同じように「.Range(LTtable).Resize(1, UBound(TitleArray, 1) + 1)」としても良いです。
250行目の「XlListObjectHasHeaders」パラメータは、図4-55の中から「見出し有り」の「xlYes」を指定しています。
列ラベルの有無
定数内容
xlGuess0Excelが判断
xlYes1見出し有り
xlNo2見出し無し(既定値)
図4-55

その他のパラメータの内、「LinkSource」は、SourceType:=xlSrcRangeを指定した場合は、必ず省略します。
また「Destination」パラメータは、SourceType:=xlSrcRangeを指定した場合は、何を設定しても無視されます。
「TableStyleName」パラメータは、省略すると図4-56のような「横縞模様」の書式になります。今回は省略しています。

この248~250行目のAddメソッドでテーブルは作成されました。作られたテーブルは、248行目「Set L = ・・・」となってるので、Lというオブジェクトとして扱えることになります。
その作られたテーブルに名前を付けるのが251行目「L.Name = TableName」です。「TableName」は図4-1の3行目で「DVDmgt」という名前で定数宣言されています。

252行目「L.ListColumns("CreateD").Range.NumberFormatLocal = "yyyy/mm/dd"」は、「作成日」列(テーブルの4列目)の書式を「"yyyy/mm/dd"」にしています。
これはテーブルの見掛けだけでは無く、データをやり取りする操作ダイアログのTextBox2の書式と合わせることで「作成日での検索を可能」にさせています。ちなみにTextBox2の書式は、図5-19の453行目で「"yyyy/mm/dd"」と同じ書式に整えています。

このテーブル製作プロシージャによって作られるテーブルは、図4-56になります。
作成されるテーブル
図4-56

5.操作ダイアログ(UserForm1)

5-1.フォーム上のコントロールの配置

フォーム上のコントロールの配置は図5-1のようにしました。
フォーム上のコントロールの配置
図5-1

入力および検索項目は以下の通りです。
項目列名コントロール注意点
DVD番号DvdNoTextBox3枚数を考慮し4桁以上
タイトルTitleTextBox1ある程度長い文字列が入る幅に
シリーズSeriesComboBox1
作成日CreateDTextBox2"yyyy/mm/dd"が入る幅に
ジャンルGenreCheckBox1~12文字列の長さを考慮して配置
保管場所StorageComboBox2
図5-2

また、検索結果を表示するListBox1をダイアログの下段に配置しています。
ボタンは「検索」「クリア」「登録/更新」「削除」「今日」「終了」の6つを配置しています。削除ボタンは誤クリックを防ぐために小さ目にしています。
その他の、説明用Label、及びFrameは、適当な位置に配置し、そのCaptionは配置時に書き込んでいます。

5-2.フォームモジュール

5-2-1.初期設定

図4-2(DVDmgtStartプロシージャ)の23行目からUserForm1が呼び出され、起動時に最初に実行されるのが図5-3のInitializeイベントです。
  1. '========== ⇩(20) フォーム起動時の設定 ============
  2. Private Sub UserForm_Initialize()
  3.  Call MakeCKB    '←CheckBoxのWidth設定+Caption書込み
  4.  Me.CommandButton1.Caption = "検索"
  5.  Me.CommandButton2.Caption = "クリア"
  6.  Me.CommandButton3.Caption = "登録/更新"
  7.  Me.CommandButton4.Caption = "削除"
  8.  Me.CommandButton6.Caption = "終了"
  9.  Me.ListBox1.ColumnCount = LastCol + 1
  10.  Me.ListBox1.ColumnWidths = "0;30;125;70;55;0;0;60"   '←ColumnWidths合計=340,ListBox.Width=343
  11.  Me.TextBox2.IMEMode = fmIMEModeDisable
  12.  Me.TextBox3.IMEMode = fmIMEModeDisable
  13.  Call MakeComboList(Me.ComboBox1, "Series")    '←シリーズBoxのリスト作成
  14.  Call MakeComboList(Me.ComboBox2, "Storage")    '←保管場所Boxのリスト作成
  15. End Sub
図5-3

259行目「Call MakeCKB」は図5-30を呼出し、12個のCheckBoxの幅を(文字列が全て見えるように)設定し、図4-3のジャンル文字列をCaptionに書き込んでいます。
フォーム配置時に設定しても良いのですが、DVD以外の管理にも展開する際には、後からマクロ設定した方が使い易いと考えました。

261~265行目は、CommandButtonの表面文字列を書き込んでいます。

267行目「Me.ListBox1.ColumnCount = LastCol + 1」は、検索結果を表示するListBox1の列数を設定しています。LastColはテーブルの列数ですので、今回は「7+1」で8列のListBoxにしています。基本的には、テーブルの列数とListBoxの列数を合わせているのですが、+1しているのは「ListBoxのゼロ列目に『データの行相対位置』を入れる」ためです。

この「データの行相対位置」をListBoxに設定しないと、ListBoxのデータ行を選択した際に得られるデータが、リストとして表示されているDVD番号などに限られます。テーブルのその行に対して処理を行うためには、DVD番号などを使って「データの行相対位置」を調べてからの処理になるため、二度手間になります。

268行目「Me.ListBox1.ColumnWidths = "0;30;125;70;55;0;0;60"」ではListBoxの各列の幅を指定しています。ListBoxに入るデータは先頭から「データの行相対位置」「DVD番号」「タイトル」「シリーズ」「作成日」「ジャンル」「ジャンル計算結果」「保管場所」ですが、リストとして表示するのは「DVD番号」「タイトル」「シリーズ」「作成日」「保管場所」の5つとしました。
それに合わせ、表示しない列は幅「ゼロ」とし、表示列は適当な幅としました。
一方、ListBox1の表示幅(.Width)は今回343ポイントでしたので、各列幅の合計は、その値から「-3」の340ポイントになるように調整しました。この関係性については「先入先出の入出庫管理システム」を参照下さい。

270行目「Me.TextBox2.IMEMode = fmIMEModeDisable」は、作成日であるTextBox2の入力時は「半角の数字他」に制限します。そのため、まずは「半角」のみにするため「日本語IME」をOFFの設定にしています。
271行目「Me.TextBox3.IMEMode = fmIMEModeDisable」のTextBox3(DVD番号)も同じ理由で、同じ設定をしています。

273行目「Call MakeComboList(Me.ComboBox1, "Series")」は、図5-12のMakeComboListプロシージャを呼出しています。引数を2個渡し、テーブルの「Series(シリーズ)」列のデータを重複無く、「Me.ComboBox1」のリストに設定する 指示を出しています。
274行目「Call MakeComboList(Me.ComboBox2, "Storage")」も同様に、テーブルの「Storage(保管場所)」列のデータを重複無く、「Me.ComboBox2」のリストに設定しています。

5-2-2.検索ボタン

操作ダイアログ上の「検索ボタン」をクリックした時に呼び出されるのが図5-4です。ボタン操作によるマクロの流れは「プログラムの流れ」の図3-2の表のようになります。コードと併せて見比べると、分かり易いかと思います。
  1. '========== ⇩(21) 検索 ============
  2. Private Sub CommandButton1_Click()
  3.  Call CondMemory    '←検索条件の記憶
  4.  Call MakeListBox    '←検索実施+リスト化
  5. End Sub
図5-4

281行目「Call CondMemory」で、図5-37のCondMemoryを呼び出し、操作ダイアログ上段の現在の検索条件を記憶します。図3-2の検索の①に相当します。
ここで記憶した内容は、下段リストボックスの項目をクリックしてから操作をする「更新」「削除」を実行した後に復元します。復元する理由は、「下段リストボックスの項目をクリックすると、クリックした項目データが上段に反映され、元の検索条件が上書きされてしまう」ためです。粗い検索条件で複数項目をリスト化し、リストの項目を次から次へと処理することを想定し、このような仕様にしました。

282行目「Call MakeListBox」で、図5-22のMakeListBoxを呼び出し、上段の各項目に入力した値を検索条件として、ダイアログ下段のリストボックスに検索結果を表示します。図3-2の検索の②に相当します。

5-2-3.クリアボタン

ダイアログ上の「クリア」ボタンをクリックした時が図5-5です。
  1. '========== ⇩(22) クリア ============
  2. Private Sub CommandButton2_Click()
  3.  Me.TextBox1.Value = ""
  4.  Me.TextBox2.Value = ""
  5.  Me.TextBox3.Value = ""
  6.  Me.ComboBox1.ListIndex = -1
  7.  Me.ComboBox2.ListIndex = -1
  8.  Me.ListBox1.Clear
  9.  Call Bit2CKB(0)
  10.  Me.TextBox3.Enabled = True
  11. End Sub
図5-5

288~293行目は、各TextBoxを空にし、各ComboBoxを未選択状態(ListIndex = -1)にし、、ListBox1をクリアしています。
295行目「Call Bit2CKB(0)」は、図5-34の「Bit2CKB」プロシージャを呼び出しています。Bit2CKBは、12個のCheckBoxのうち「引数で指定されたCheckBoxにレ点をつける」マクロです。「ゼロ」を指定することで「全てのCheckBoxのレ点を外す」ようにしています。

297行目「Me.TextBox3.Enabled = True」は、DVD番号欄のTextBox3をユーザー操作可能(Enabled = True)な状態に変更しています。
TextBox3は、検索後リストボックスの項目を選択すると同時に、ユーザー操作を不可(Enabled = False)にしていますが、これは「リスト選択状態は、選択した項目を「更新」または「削除」するため、DVD番号が変更できない」ようにするためです。
クリアボタンでリストボックスもクリアする訳ですから、TextBox3も検索可能なように操作可能な状態に変更しています。

5-2-4.登録/更新ボタン

操作ダイアログの「登録/更新」ボタン(CommandButton3)をクリックした時に呼び出されるのが図5-6です。
  1. '========== ⇩(23) 登録/更新 ============
  2. Private Sub CommandButton3_Click()
  3.  Dim DvdNum As Long      '←選択しているDVD番号
  4.  Dim RowNum As Long      '←選択しているテーブル行相対位置
  5.  If Trim(Me.TextBox1.Text) = "" Then
  6.   MsgBox "タイトルは入力必須項目です"
  7.   Me.TextBox1 = ""
  8.   Me.TextBox1.SetFocus
  9.   Exit Sub
  10.  End If
  11.  If Me.ListBox1.ListIndex = -1 Then    '←'新規登録状態
  12.   Call CondMemory
  13.   Call TableDataIn(LastLrow + 1, InData(TableMax("DvdNo") + 1))
  14.   Call myMsgBox("新規登録しました")
  15.  Else      '←更新状態
  16.   RowNum = Me.ListBox1.List(Me.ListBox1.ListIndex, 0)
  17.   DvdNum = Me.ListBox1.List(Me.ListBox1.ListIndex, 1)
  18.   Call TableDataIn(RowNum, InData(DvdNum))
  19.   Call myMsgBox("データ更新しました")
  20.   Me.TextBox3.Enabled = True
  21.  End If
  22.  Call MakeComboList(Me.ComboBox1, "Series")
  23.  Call MakeComboList(Me.ComboBox2, "Storage")
  24.  Call CondRestore
  25.  Call MakeListBox    '←上段の項目で検索実施、リスト化
  26. End Sub
図5-6

本システムでは「DVDのタイトルは必須」としています。ですので307~312行目で、タイトルが入力されているか否かのチェックを行います。
307行目「If Trim(Me.TextBox1.Text) = "" Then」は、タイトル入力部に「スペースだけを入力」した時も逃さないように「Trim関数」を使って「先頭と末尾の半角/全角のスペース」を削除してから、空白(長さゼロの文字列)か否かをチェックしています。
空白の場合は、308行目「MsgBox "タイトルは入力必須項目です"」でコメントを出し、309行目「Me.TextBox1 = ""」で余分なスペースを取り除き、310行目「Me.TextBox1.SetFocus」でフォーカスをタイトル部に戻してから、311行目「Exit Sub」で「登録/更新処理を中止」しています。

タイトルが空白で無かった場合は、314行目「If Me.ListBox1.ListIndex = -1 Then」で「リストボックスが選択状態か否か」を確認します。
下段のリストボックスが選択されていれば、その情報詳細が上段の各項目に反映された状態です。その状態から「登録/更新」ボタンをクリックしているのですから、「更新」ということになります。
一方リストボックスが選択されていないのであれば、新たに各項目に入力した、またはリストをダブルクリックしてデータをコピーしたことになりますので、「新規登録」を表しています。

ということで、リストボックスが選択状態では無い(=新規登録)場合は316~319行目を実行し、選択状態(=更新)の場合は322~327行目を実行します。

5-2-4-1.新規登録処理
316行目「Call CondMemory」では、ダイアログ上段の入力内容を記憶させています。これは、図3-2の新規登録の①に相当します。

次に318行目「Call TableDataIn(LastLrow + 1, InData(TableMax("DvdNo") + 1))」で、標準モジュールのTableDataIn(図4-33)を呼び出し、テーブルにデータを「新規データ」として追加します。
TableDataInプロシージャへは2つの引数を渡します。第一引数は「書き込むテーブルの行相対位置」です。
「新規データ」ですから、図5-7のように「テーブルの下端(LastLrow)の1つ下(+1)」にデータを書きこむことで、自動的にテーブルの範囲も広がり、新規データがテーブルに取り込まれます。
新規データの追加位置
図5-7

第二引数は「書き込む内容」で、操作ダイアログ上段の各項目を配列にしたものを渡します。
項目を配列化するのは「InData関数プロシージャ(図5-36)」の役目ですが、このInData関数は「新規登録」だけでなく「更新」でも使用します。「新規登録」と「更新」では、上段の各項目を配列化することは同じなのですが、「DVD番号」だけは「既存には無い番号」か「既存の番号」と異なります。その異なるDVD番号を引数として渡すことで、関数プロシージャの共通化を図っています。

ここでは新規登録データ用のDVD番号を求めるため、まずテーブルの指定列の最大値を求める「TableMax関数(図4-32)」を使い、引数に「計算する列名」である「"DvdNo"」を指定して、現在のDVD番号の列の中の最大値を取得します。そして得られた「DVD番号の最大値」に「+1」した値を「DVD番号」としてInData関数に渡すことで、書き込み内容の配列を完成させ、TableDataInプロシージャの第二引数に渡しています。

TableDataInプロシージャは、図5-7のように「テーブルの下端の1つ下(第一引数)」に、「操作ダイアログで入力したデータ+新DVD番号(第二引数)」の書き込み処理を行い、その後の319行目「Call myMsgBox("新規登録しました")」で、短い時間(今回設定=0.5秒間)だけメッセージを出し、データが追加されたことをユーザーに伝えます。

その後は「更新」と同じコードを実行することになりますが、
331行目「Call MakeComboList(Me.ComboBox1, "Series")」で、シリーズ(ComboBox1)のリストを作り直し、332行目「Call MakeComboList(Me.ComboBox2, "Storage")」で、保管場所(ComboBox2)のリストを作り直しています。
作り直す理由は、新規の場合は「シリーズや保管場所が新しく追加」されている可能性がありますし、また更新の場合には「シリーズや保管場所の追加・変更・削除」が考えられるためです。

次に333行目「Call CondRestore」で、ダイアログ上段を元の入力状態に復元します。新規入力の場合は復元は必要なさそうに思えますが、実は入力時にComboBoxで選択・入力していても、331~332行目でComboBoxのリストを作り直してしまいますので、新規追加処理後は、ComboBoxが未選択状態になっています。
ですので、ComboBoxのリストの作り直し(331~332行目)の後に、316行目で記憶しておいたデータの復元が必要となります。
また「更新」時には、「検索」時に記憶しておいた内容を上段項目に復元します。

最後に334行目「Call MakeListBox」で、「ダイアログ上段の項目」を使ったデータ検索を行い、その結果を下段のリストボックスに表示させています。

5-2-4-2.更新処理
一方「更新」の時には、322行目「RowNum = Me.ListBox1.List(Me.ListBox1.ListIndex, 0)」で、下段リストボックスの選択項目の、「ゼロ列目」の「テーブルの行相対位置」を変数RowNumに代入します。
また、323行目「DvdNum = Me.ListBox1.List(Me.ListBox1.ListIndex, 1)」では、「1列目」の「DVD番号」を変数DvdNumに代入します。

325行目「Call TableDataIn(RowNum, InData(DvdNum))」では、TableDataInプロシージャ(図4-33)を呼び出し、テーブルにデータを「更新データ」として上書きします。
TableDataInプロシージャへの第一引数は「書き込むテーブルの行相対位置」で、322行目で取得したRowNumとなります。
また、第二引数の「書き込む内容」は、InData関数プロシージャを通じて配列化しますが、その中のDVD番号には323行目で取得したDvdNumを使います。

本来は「DVD番号は変えずに、その他のデータを更新」すれば良いのですが、テーブルへのデータ記入のプログラムを新規登録と共通にするため、InData関数プロシージャの引数にDVD番号を指定する方法を考えました。

326行目「Call myMsgBox("データ更新しました")」で、一瞬だけメッセージを表示させたのち、327行目「Me.TextBox3.Enabled = True」で、ダイアログ上段の「DVD番号」のTextBox3をユーザー入力が可能な状態にしています。
この「DVD番号」欄は、検索後リストボックスの項目を選択した際に、図5-27の519行目「Me.TextBox3.Enabled = False」で入力不可状態にしていますが、データ更新が完了した際には「リストボックスは未選択状態」になっていますので、TextBox3を入力可能状態に変更しています。

その後の331~334行目のコードは「新規登録時」と同じです。
331~332行目で2つのコンボボックスのリストを作り直し、333行目で「検索」時に記憶しておいた内容を上段項目に復元し、334行目で「上段の項目(=一番最近の検索条件)」を元にデータ検索・リスト化を行うことで、一つ前の検索リストが復元され、都度検索をやり直す事無く、複数行の検索データでの連続した更新作業が可能となります。

5-2-5.削除ボタン

操作ダイアログの「削除」ボタンをクリックした時に呼び出されるのが、図5-8です。
  1. '========== ⇩(24) 削除 ============
  2. Private Sub CommandButton4_Click()
  3.  Dim DvdNum As Long      '←選択しているDVD番号
  4.  Dim RowNum As Long      '←選択しているテーブル行相対位置
  5.  Dim Title As String       '←選択しているタイトル
  6.  Dim Msg As String       '←メッセージ表示する文字列
  7.  If Me.ListBox1.ListIndex = -1 Then
  8.   MsgBox "リストから削除する項目を選択後に、削除処理をして下さい。"
  9.   Exit Sub
  10.  End If
  11.  RowNum = Me.ListBox1.List(Me.ListBox1.ListIndex, 0)
  12.  DvdNum = Me.ListBox1.List(Me.ListBox1.ListIndex, 1)
  13.  Title = Me.ListBox1.List(Me.ListBox1.ListIndex, 2)
  14.  Msg = "No=" & DvdNum & "「" & Title & "」" & vbNewLine & "を削除して良いですか?"
  15.  If MsgBox(Msg, vbYesNo) = vbYes Then
  16.   Call TableDataDel(RowNum)
  17.   Call myMsgBox("データ削除しました")
  18.   Me.TextBox3.Enabled = True
  19.   Call MakeComboList(Me.ComboBox1, "Series")
  20.   Call MakeComboList(Me.ComboBox2, "Storage")
  21.   Call CondRestore
  22.   Call MakeListBox
  23.  End If
  24. End Sub
図5-8

データを削除するには「削除する項目を指定」する必要があります。今回システムでは「下段のリストボックスで選択した項目が削除対象」としています。
345行目は「リストボックスの項目を選択しているか否か」を「If Me.ListBox1.ListIndex = -1 Then」で調べ、選択状態でなかったら、346行目「MsgBox "リストから削除する項目を選択後に、削除処理をして下さい。"」でコメントを出し、347行目「Exit Sub」で「削除工程を中止」させています。

350行目「RowNum = Me.ListBox1.List(Me.ListBox1.ListIndex, 0)」は、下段リストボックスの選択項目の、「ゼロ列目」の「テーブルの行相対位置」を変数RowNumに代入します。
351行目「DvdNum = Me.ListBox1.List(Me.ListBox1.ListIndex, 1)」は、「1列目」の「DVD番号」を変数DvdNumに代入します。
352行目「Title = Me.ListBox1.List(Me.ListBox1.ListIndex, 2)」は、「2列目」の「タイトル」を変数Titleに代入します。
353行目「Msg = "No=" & DvdNum & "「" & Title & "」" & vbNewLine & "を削除して良いですか?"」は、上記「DVD番号」と「タイトル」を組合せて、メッセージボックスに表示する文字列を組み立てています。

355行目「If MsgBox(Msg, vbYesNo) = vbYes Then」では、メッセージボックスの文字列として353行目で組み立てた文字列を使い、またボタンとして「はい」「いいえ」の2つのボタン(vbYesNo)を設定します。メッセージは、図2-14のような感じに表示されます。
メッセージボックスの「はい」(= vbYes)を押すと、356~365行目の削除処理を行います。なお「いいえ」を押した時はIf文を抜け、削除処理を中止します。

356行目「Call TableDataDel(RowNum)」では、標準モジュールのTableDataDelプロシージャ(図4-35)を呼出し、引数で渡した「テーブルの行相対位置(RowNum)」のデータを削除します。
削除後は、357行目「Call myMsgBox("データ削除しました")」で、一瞬だけコメントを出します。

359行目「Me.TextBox3.Enabled = True」では、リストボックスの選択項目が削除されたのですから、リスト選択されてない状態になり、DVD番号欄のTextBox3をユーザー入力可の状態にしています。

項目が1つ削除されたので、コンボボックスのリストが変更になる可能性があります。ですので361行目「Call MakeComboList(Me.ComboBox1, "Series")」で、ComboBox1(シリーズ)のリストを作り直し、362行目「Call MakeComboList(Me.ComboBox2, "Storage")」で、ComboBox2(保管場所)のリストを作り直しています。

364行目「Call CondRestore」では、ダイアログ上段の各項目を検索時の状態に復元し、その項目を使って365行目「Call MakeListBox」でデータ検索をし、下段のリストボックスに表示させています。
前回検索時に複数行の検索結果が得られているのであれば、削除後は1つ減った項目がリスト表示されるはずです。前回1行だけが検索され、それを削除したのであれば、リストボックスは空になるはずです。

5-2-6.日付/終了ボタン

ダイアログの作成日欄(TextBox2)の右側にある「今日」というボタンをクリックすると図5-9が起動します。
  1. '========== ⇩(25) 今日の日付ボタン ============
  2. Private Sub CommandButton5_Click()
  3.  Me.TextBox2.Text = Format(Date, "YYYY/MM/DD")
  4. End Sub
図5-9

372行目「Me.TextBox2.Text = Format(Date, "YYYY/MM/DD")」は、作成日欄(TextBox2)に「YYYY/MM/DD」の書式で「今日の日付」を入力します。
なお今回システムでは、正しい日付か否かを、TextBox2の「Exitイベントプロシージャ(図5-19)」でチェックをしています。ですので作成日欄に日付では無い数字等を入力後、この「今日ボタン(CommandButton5)」を押しても、ボタンのClickイベントよりも前にTextBox2のExitイベントが発生してしまいますので、「『今日ボタン』をクリックしているのに、作成日欄に今日の日付が入らない」という現象になります。

ダイアログの「終了ボタン」をクリックしたときに呼び出されるのが、図5-10です。
  1. '========== ⇩(26) 終了ボタン ============
  2. Private Sub CommandButton6_Click()
  3.  Unload Me
  4. End Sub
図5-10

377行目「Unload Me」でダイアログを閉じています。
こちらも、作成日欄に日付では無い数字等を入力後「終了ボタン(CommandButton6)」を押しても、ボタンのClickイベントよりも前にTextBox2のExitイベントが発生してしまいますので、「『終了』をクリックしているのに、ダイアログが閉じない」という現象になります。

ダイアログと閉じる最中、およびダイアログの右上×印で閉じようとした時に発生するイベントが図5-11です。
  1. '========== ⇩(27) ダイアログを閉じる ============
  2. Private Sub UserForm_QueryClose(Cancel As Integer, CloseMode As Integer)
  3.  Me.TextBox2 = ""
  4. End Sub
図5-11

382行目「Me.TextBox2 = ""」は、作成日欄(TextBox2)を空にしています。
ダイアログ右上×印で閉じる場合は、TextBox2のExitイベントよりも前にこのQueryCloseイベントが発生します。ですので、万一作成日欄に日付以外の数字等が入っていても、まずここで作成日欄を空にしてくれるため、Exitイベントでの日付チェックで引っ掛からなくなり、素直にダイアログが終了できます。

5-2-7.コンボボックスの作成・処理

起動時(図5-3)、及びデータに変更を加えた時(図5-6、図5-8)に呼び出され、今回2箇所のComboBoxのリストを作成するのが図5-12です。
第一引数(CB)として「リストを作成するComboBoxコントロール」、第二引数として「テーブルの列名」を受け取ります。
なお今回システムのコンボボックスは、データが1列だけのリストで、且つ一番上に「(新規)」という項目を必ず置きます。「(新規)」をクリックするとInputBoxが起動し「新規項目を追加」できる仕様にしています。
  1. '========== ⇩(28) コンボボックスのリスト作成 ============
  2. Private Sub MakeComboList(CB As ComboBox, Col As String)
  3.  Dim buf As Variant      '←指定列の重複の無いデータ配列
  4.  Dim i As Long        '←カウンタ変数(リストの数)
  5.  CB.Clear
  6.  CB.AddItem "(新規)"
  7.  buf = uniList(Col)
  8.  If IsEmpty(buf) Then Exit Sub
  9.  For i = 1 To UBound(buf, 1)
  10.   CB.AddItem buf(i)
  11.  Next i
  12. End Sub
図5-12

390行目「CB.Clear」は、例えば「UserForm1.ComboBox1.Clear」と同じですので、「対象のコンボボックスのリストを空」にしています。
391行目「CB.AddItem "(新規)"」は、まず1番目の項目として「(新規)」という項目をリストにします。

393行目「buf = uniList(Col)」は、図4-41を呼出し、テーブルから「指定列名(引数であるCol)」の重複の無いデータ配列を取得し、変数bufに代入します。
394行目「If IsEmpty(buf) Then Exit Sub」では、もしデータが1つも無くEmptyが戻ってきた時には、対象コンボボックスのリスト作成を終了します。この時すでに「(新規)」はリストにAddItemされていますので、「(新規)」のみのリストとなります。

396~398行目のFor~Nextで、テーブルからの重複無いデータ配列を全てリストに追加していきます。
396行目「For i = 1 To UBound(buf, 1)」で、カウンタ変数iをデータ配列bufの行数分だけ回します。なお、uniList(図4-41)から戻してくる配列のインデックスは、1から始めています。
397行目「CB.AddItem buf(i)」では、データ配列bufのデータを順にリストに追加しています。

シリーズ用のComboBox1のリストを選択(正確には、リストの選択を変更)した場合に発生するChangeイベントプロシージャが図5-13です。
  1. '========== ⇩(29) ComboBox1のリストを選択した場合 ============
  2. Private Sub ComboBox1_Change()
  3.  Call ComboBoxChange(Me.ComboBox1, "シリーズ名")
  4. End Sub
図5-13

404行目「Call ComboBoxChange(Me.ComboBox1, "シリーズ名")」では、ComboBoxChangeプロシージャ(図5-15)を呼び出しています。第一引数には「操作するコントロール(ここでは、自コントロール=ComboBox1)」を、第二引数には「項目名」を文字列で渡します。第二引数は、コンボボックスに間違った入力をしようとした時に出される「コメント」の言葉となります。

保管場所用のComboBox2のリストを選択した場合に発生するChangeイベントプロシージャが図5-14です。
  1. '========== ⇩(30) ComboBox2のリストを選択した場合 ============
  2. Private Sub ComboBox2_Change()
  3.  Call ComboBoxChange(Me.ComboBox2, "保管場所")
  4. End Sub
図5-14

409行目「Call ComboBoxChange(Me.ComboBox2, "保管場所")」もCombBox1と同様にComboBoxChangeプロシージャを呼び出します。ComboBox2は保管場所用ですので、第二引数は「保管場所」とします。

ComboBox1(図5-13)、及びComboBox2(図5-14)のChangeイベントプロシージャから呼び出されるのが図5-15です。
第一引数として「処理をするコンボボックスコントロール」、第二引数として「コメントに使うコンボボックスの項目名」を受取ります。
  1. '========== ⇩(31) コンボボックスへの新規項目追加 ============
  2. Private Sub ComboBoxChange(CB As ComboBox, Word As String)
  3.  Dim Ans As String        '←InputBoxからの戻り値
  4.  If CB.ListIndex = 0 Then
  5.   Ans = InputBox("新しい" & Word & "を入力して下さい")
  6.   If StrPtr(Ans) = 0 Then
  7.    CB.ListIndex = -1
  8.   ElseIf Trim(Ans) = "" Then
  9.    MsgBox Word & "が無効です"
  10.    CB.ListIndex = -1
  11.   Else
  12.    CB.AddItem Trim(Ans)
  13.    CB.ListIndex = CB.ListCount - 1
  14.   End If
  15.  End If
  16. End Sub
図5-15

416行目「If CB.ListIndex = 0 Then」で、ユーザーがComboBoxのリストの1番上の項目(リストはインデックス=ゼロから始まる)を選択した時のみ、417~427行目を実行します。「リストの1番上の項目」は、図5-12の391行目で最初にリスト設定した「(新規)」の項目になります。
それ以外の選択時には何も実行せずに、普通のComboBoxの動作になります。

1番上の項目「(新規)」を選択した時は、まず417行目「Ans = InputBox("新しい" & Word & "を入力して下さい")」でInputBox(図2-4右)を表示します。
InputBoxには、操作する場所が3箇所(「OKボタン」「キャンセルボタン」「ダイアログの右上×印」)ありますが、「キャンセルボタン」および「ダイアログの右上×印」の場合には「ゼロの文字列」を戻して来ます。
一方「OKボタン」の場合は、入力テキストボックスに入力した値が戻ってきます。

419~427行目のIf~End If文で、その戻り値を仕訳けるのですが、仕訳ける順番には注意が必要です。
ユーザー操作によってInputBoxの戻り値を判定した結果がどうなるか を図5-16に整理しました。
ここでは、戻り値を「Ans」とし、表中の記号が「〇はTrue、×はFalse」を表しています。
Ans=""Trim(Ans)=""StrPtr(Ans)=0
何も入力せずにOK×
スペースを入れOK××
文字列を入れOK×××
キャンセルで閉じる
右上×印で閉じる
図5-16

イミディエイトウィンドウ等で表示されるAnsの値を見ると、①④⑤は全て「""」となっており見分けがつかない(Ans="")のですが、「StrPtr関数」を使うと①と④⑤を仕訳けることが出来ます。
ですので、まず④⑤を分離するために、419行目「If StrPtr(Ans) = 0 Then」で仕訳けをします。ユーザーが「キャンセルボタン」や「ダイアログの右上×印」をクリックする理由は、「思った項目と違うのを選択してしまった」「気が変わった」等だと思いますので、特にメッセージは出さずに420行目「CB.ListIndex = -1」でComboBoxを未選択状態にしています。

次に①②をまとめて421行目「ElseIf Trim(Ans) = "" Then」で仕訳けています。①②は、ユーザーが「操作を間違えている」「意味の無いスペースを登録しようとしている」等と判断できるので、422行目「MsgBox Word & "が無効です"」でメッセージを出し、423行目「CB.ListIndex = -1」でComboBoxを未選択状態にしています。

最後に残った(424行目「Else」)のが③ですので、425行目「CB.AddItem Trim(Ans)」でリストの一番最後にInputBoxで入力した項目を追加し、426行目「CB.ListIndex = CB.ListCount - 1」で、その項目を選択状態にします。

5-2-8.数値用テキストボックスの処理

作成日用のTextBox2は、図5-3の270行目で「日本語IMEをOFF」にしているため、一応半角のみを受け付けるようになっています。(「一応」というのは、キーボードから入力した場合は制限できるが、コピペされると何の文字列でも許してしまうと言う意味です。このコピペにも対応しようとするのであれば「先入先出の入出庫管理システム」を参照下さい。)

しかし日付を入力するのに「A」や「B」などのアルファベットは不要ですし、「&」のような記号も使いません。日付で使うのは(=Excelが日付と読み取ってくれるのは)、「2021/10/20」とか「2021-10-20」のように「数字」と「/」「-」だけです。
ですので、作成日用のTextBox2に入力できる文字種を「数字」「/」「-」に制限するのが図5-17です。
  1. '========== ⇩(32) 作成日欄の文字種制限 ============
  2. Private Sub TextBox2_KeyPress(ByVal KeyAscii As MSForms.ReturnInteger)
  3.  If (Chr(KeyAscii) < "0" Or Chr(KeyAscii) > "9") And _
  4.     (Chr(KeyAscii) <> "/" And Chr(KeyAscii) <> "-") Then
  5.   KeyAscii = 0
  6.  End If
  7. End Sub
図5-17

このKeyPressイベントでは、キーボードで押したキーを引数KeyAsciiとして受け取ります。そのKeyAsciiの種類を調べ、通して良いキーであれば何もせず、通していけないキーの場合は「キーを無効」にするのが今回の手法です。

433~434行目は、以下の3つの式を「And」でつなげたものです。
 ① (Chr(KeyAscii) < "0" Or Chr(KeyAscii) > "9")
 ② Chr(KeyAscii) <> "/"
 ③ Chr(KeyAscii) <> "-"
横軸をキーコードにして3つの式を図で示してみると図5-18のようになります。
キーの制限
図5-18

薄い青の部分は式が成立する範囲です。Andでつなげると言う事は「全ての式が成立する範囲」となりますので、①②③が全て薄い青である「濃い青の範囲」となり、目的とする「数字」「/」「-」以外の時に成立する式となります。
「数字」「/」「-」以外の時に実行されるのが、435行目「KeyAscii = 0」で、キーが無効になります。

作成日用のTextBox2は、入力された値が「日付」で無いと後から検索なども出来なくなってしまいます。日付か否かのチェックのタイミングとして「キー入力の都度、日付の並びに従っているかチェック」「入力欄を出る時にチェック」「登録/更新ボタンをクリックした時にチェック」など、色々なやり方があると思います。
今回は「日付欄を出る時」に、ちゃんと「日付として入力されたか」をチェックし、もし「日付で無かったら日付欄を抜け出せない」ようにしました。

具体的には、Exitイベント(TextBox2から他のコントロールに移動する時に発生)を使用した図5-19になります。引数の「Cancel」にTrueを設定すると「ExitイベントがCancel(=移動しない)」します。
  1. '========== ⇩(33) 作成日欄の日付チェック ============
  2. Private Sub TextBox2_Exit(ByVal Cancel As MSForms.ReturnBoolean)
  3.  If Trim(Me.TextBox2.Text) = "" Then
  4.   Me.TextBox2.Text = ""
  5.  Else
  6.   If Not IsDate(Me.TextBox2.Text) Then
  7.    MsgBox "日付ではありません。修正して下さい。"
  8.    Cancel = True
  9.    Me.TextBox2.SelStart = 0
  10.    Me.TextBox2.SelLength = Len(Me.TextBox2.Value)
  11.   Else
  12.    Me.TextBox2.Text = Format(Me.TextBox2.Text, "YYYY/MM/DD")
  13.   End If
  14.  End If
  15. End Sub
図5-19

441行目「If Trim(Me.TextBox2.Text) = "" Then」で、TextBox2が「空」または「スペースのみ」の場合は、443行目「Me.TextBox2.Text = ""」でTextBox2を「空」にして、Exitイベントプロシージャを抜け出します。ExitイベントをCancelせずに抜け出しますので、他のコントロールに移動できる(例:ボタンをクリック出来る)ことになります。
なお、TextBox2にキーボードから「スペース」は入力できませんので、コピペされた場合を想定しています。

その他(スペース以外の文字列がTextBox2に入力されている)の場合は、447~454行目を実行することになります。
447行目「If Not IsDate(Me.TextBox2.Text) Then」は、TextBox2の文字列が日付として成立しているかを確かめ、成立していなかった時に448~451行目を実行します。
TextBox2の文字列が日付では無い場合、448行目「MsgBox "日付ではありません。修正して下さい。"」でメッセージを出し、449行目「Cancel = True」で「TextBox2からの移動を中止」させます。

450~451行目は図5-20の右上のように、ユーザーが入力した文字列を「選択状態」にして「間違えている所を強調」しています。この設定が無い場合には、図5-20の右下のように「文字列の末尾にカーソル」がある状態になります。
TextBox内の文字列を選択
図5-20

450行目「Me.TextBox2.SelStart = 0」は、TextBox2の文字列の「ゼロ」文字目から選択状態にしています。
次に451行目「Me.TextBox2.SelLength = Len(Me.TextBox2.Value)」では、TextBox2の「全ての文字列の文字数分」を選択状態にします。
なお、450行目・451行目のどちらか一方が欠けても、文字列は選択状態になりません。

このシステムでは、ExitイベントをCancel(449行目)した状態から「文字列を選択(450~451行目)」していますので、TextBox2の文字列が選択状態になります。しかし、他のコントロールにFocusが有る状態から「TextBox2の文字列を選択」のコードを実行しても何も起こりません。
今回は必要ありませんが、もしその様な状況の時は、まず「Me.TextBox2.Focus」でFocusをTextBox2に移してから「TextBox2の文字列を選択」する必要があります。前述した「登録/更新ボタンをクリックした時に日付をチェック」した場合には、まずCommandButtonからTextBox2にFocusを移しておく必要があります。

TextBox2の文字列が「日付として成立」している時には、453行目「Me.TextBox2.Text = Format(Me.TextBox2.Text, "YYYY/MM/DD")」で、「TextBox2の文字」を「"YYYY/MM/DD"」のフォーマットでTextBox2に書き込んでいます。
フォーマットを整えているのは、テーブルに書き込むデータとして日付形式を整えているのでは無く、検索でのフォーマットを合わせるためです。
また別な目的としては、例えば「4/12」とするところを間違えて「41/2」と入力してしまった場合は、「2041/02/01」と変換されるため間違えに気付くのを期待しています。
寄り道
テーブルに書き込むデータとしてここでフォーマットを整えなくても、テーブル側のフォーマットは図4-52の252行目で整えていますので、書き込みには問題は出ないと思います。
しかし、この作成日欄は「検索」時にも使用します。もし検索する側と検索される側のフォーマットが異なっていると、「検索文字列とテーブル側データの文字列が違うためヒットしない」ことになります。テーブル側のフォーマットとTextBox2側のフォーマットを合わせる事は必須です。

DVD番号欄(TextBox3)の入力文字種を制限するのが、図5-21です。
  1. '========== ⇩(34) DVD番号欄の文字種制限 ============
  2. Private Sub TextBox3_KeyPress(ByVal KeyAscii As MSForms.ReturnInteger)
  3.  If Chr(KeyAscii) < "0" Or Chr(KeyAscii) > "9" Then
  4.   KeyAscii = 0
  5.  End If
  6. End Sub
図5-21

ほとんど図5-17と同じですが、キーの種類を調べる461行目「If Chr(KeyAscii) < "0" Or Chr(KeyAscii) > "9" Then」だけが異なります。
DVD番号は、今回システムでは「正の整数」としていますので、入力して良いのは「0~9」のキーのみです。
そのため、461行目の条件式のみにしています。

5-2-9.リストボックスの作成・処理

検索が実行された時、および新規登録・更新・削除が実行された後に呼び出されるのが、図5-22です。
このプロシージャは、操作ダイアログ上段の各項目の値を調べ、各値により絞り込まれたデータをダイアログ下段のリストボックスに表示するものです。
  1. '========== ⇩(35) リストボックスの作成 ============
  2. Private Sub MakeListBox()
  3.  Dim buf As Variant      '←テーブルの絞込データの配列
  4.  Dim ckb As Long        '←CheckBox12個の選択状態
  5.  Dim i As Long         '←カウンタ変数(絞込データの行数)
  6.  Dim j As Long         '←カウンタ変数(テーブルの列数)
  7.  Application.ScreenUpdating = False
  8.   Call TableFilterOFF
  9.   Call TableSort("DvdNo")
  10.   Call TableFilter(1, Me.TextBox3.Text)
  11.   Call TableFilter(2, Trim(Me.TextBox1.Text), "*")
  12.   Call TableFilter(3, Trim(Me.ComboBox1.Text), "*")
  13.   Call TableFilter(4, Me.TextBox2.Text)
  14.   Call TableFilter(7, Trim(Me.ComboBox2.Text))
  15.   ckb = CKB2Bit
  16.   If Not ckb = 0 Then
  17.    ThisWorkbook.Sheets(ShName).Range(SearchGenre) = ckb
  18.    Call TableFilter(6, ">0")
  19.   End If
  20.   buf = SearchList
  21.   Call TableFilterOFF
  22.   Me.ListBox1.Clear
  23.   If Not IsEmpty(buf) Then
  24.    For i = 1 To UBound(buf, 1)
  25.     Me.ListBox1.AddItem ""
  26.     For j = 0 To LastCol
  27.      Me.ListBox1.List(i - 1, j) = buf(i, j)
  28.     Next j
  29.    Next i
  30.   End If
  31.  Application.ScreenUpdating = True
  32. End Sub
図5-22

473行目「Application.ScreenUpdating = False」と503行目「Application.ScreenUpdating = True」で囲まれた範囲は、Excelの画面更新を中止しています。これは、テーブルの絞り込みとフィルター解除による画面ちらつきを抑えるためです。但し画面更新中止は、テーブルが見える状態の時に有効で、Excelアドインとして動かしている場合は、役に立っていないと思われます。

475行目「Call TableFilterOFF」では図4-28を呼出し、テーブル全列のフィルター解除をしています。
これは絞込を開始する前に、どこかの列で絞り込みがされていると、絞込結果が誤ったものになるからです。一応今回システムの中では、絞込を行った後には「必ずTableFilterOFFの呼出し」によりフィルター解除をしていますが、念を入れて実行前にも解除を行っています。

次に476行目「Call TableSort("DvdNo")」で、テーブルの1列目(DvdNo列)で「降順」に並べ直しています。
これにより「新しい項目がリストボックスの上側に表示」されるようになります。

「各列の絞り込み」は、477~487行目で行います。
絞込には「完全一致」「前方一致」「後方一致」「部分一致」等の方法がありますので、各列にたいしてどの方法にするかを図5-23に整理しました。
列名コントロール絞込方法
1DvdNoTextBox3正の整数完全一致
2TitleTextBox1(長い)文字列部分一致
3SeriesComboBox1(長い)文字列部分一致
4CreateDTextBox2yyyy/mm/ddの日付完全一致
5GenreCheckBox1~1212ジャンルのON-OFF指定ジャンルを含むものを抽出
7StorageComboBox2文字列?、整数?完全一致
図5-23

この中で「Storage(保管場所)」を「部分一致」が良いのか「完全一致」が良いのか迷いました。
例えば保管場所として「A-01」「B-02」などと分類するのでしたら、検索時に「A-」の付く場所単位で探す事があるでしょうから、部分一致の方が適している気がします。しかし「100」「101」など数字のみを使った場合は、例えば「10」で部分一致を期待して検索しても抽出されません(数値としてテーブルに保存されている場合)。また「101」とフルに指定しても、部分一致の場合には何も抽出されません。
一方、完全一致で検索するのであれば、数値の形で保管場所データが保存されていても抽出はされます。

ですので本来は、各列をどんな番号・文字列の体系にするのかをまず決め、その上で検出方法を考えることが重要です。今回は保管場所を文字列・数値どちらで設定しても、フルワードで検索さえすれば探し出せる「完全一致」としました。
よりみち」に、文字列・数値混在の場合の改善策を考えましたので、そちらも参考にして下さい。

図5-23の表に基づいて、477~481行目の絞込を実施します。呼び出すTableFilterプロシージャの第一引数にはテーブルの列番号、第二引数は、絞り込み文字列、第三引数(省略可)は、部分一致の「*(アスタリスク)」を指定します。
テーブルの2・3列目は図5-23で「部分一致」としましたので「"*"」を第三引数に指定します。各項目の絞り込み指定は以下のようなコードになります。
列名コード内容コード番号
DvdNoCall TableFilter(1, Me.TextBox3.Text)477行目
TitleCall TableFilter(2, Trim(Me.TextBox1.Text), "*")478行目
SeriesCall TableFilter(3, Trim(Me.ComboBox1.Text), "*")479行目
CreateDCall TableFilter(4, Me.TextBox2.Text)480行目
StorageCall TableFilter(7, Trim(Me.ComboBox2.Text))481行目

残っているのはテーブル5列目の「ジャンル」です。ジャンルの絞り込みをしているのは483~487行目です。
483行目「ckb = CKB2Bit」は、図5-31のCKB2Bit関数プロシージャを呼び出し「12個のCheckBoxのON-OFF状態」を数値に変換しています。
変換の方法は図5-24のように、チェックボックスがONの時は「1」、OFFの時は「ゼロ」とし、12個のチェックボックスの状態を順番に並べます。すると「1とゼロの並び」となりますので、それを「2進数」として読み取り10進数の形にします。この操作により、12個のチェックボックスの状態を1つの数値に置き換えることができます。
12個のジャンルON-OFFの数値化
図5-24

483行目では、操作ダイアログ上の12個のCheckBoxの状態が、変数ckbに代入されます。
484行目「If Not ckb = 0 Then」では、そのckbの値がゼロで無い時に、485~486行目を実行します。「ckbの値がゼロ」とは「全てのCheckBoxがOFFの時」ですので、「1つ以上のCheckBoxにレ点が付いている時」ということになります。

一方テーブル側の方には、図5-25のように、Gbit列(F列)に数式「=BITAND([@Genre]),$J$1」が埋め込まれています。
ジャンルのBITANDの計算式
図5-25

数式の「BITAND関数」は、引数である2つの数値のビット単位の論理積(AND)を求めるものです。手計算で言えば、2つの数値を「2進数」で表し、各桁ごとに「ビットが両方とも1」の時だけ、結果側が1(ゼロとゼロ、1とゼロ、ゼロと1 は全てゼロ)にする計算です。全桁が計算完了したら、それを10進数に変換します。

今回のBITANDの引数は、1つ目は「@Genre]」で「同じデータ行のGenre列の値」(@は同じデータ行 の意味)、2つ目は「$J$1」で「J1セル」固定になっています。「J1」セルは、図4-1の4行目で定数「SearchGenre」と宣言しています。

485行目「ThisWorkbook.Sheets(ShName).Range(SearchGenre) = ckb」では、その「SearchGenre」で示すセル(=J1セル)に、「検索する12個のCheckBoxのON-OFF状態」の数値を書き込んでいます。
ということは図5-26のように、Gbit列(F列)の数式「=BITAND([@Genre]),$J$1」は、「Genre列の値」と「検索するCheckBoxのON-OFF状態の値」のビット単位論理積を求める ことになります。

ジャンルの絞り込み
図5-26

Gbit列の数式の計算結果は、「1桁もビットが重なっていなかったらゼロ」「1桁でもビットが重なっていたら、1以上」になります。 「1桁以上のビットが重なる」ということは、「検索するジャンルの中に、1つ以上同じジャンルがある」ことです。
ですので486行目「Call TableFilter(6, ">0")」で、「テーブルの6列目(Gbit列)を ">0" で絞り込む」ことで、ジャンルで絞り込むことになります。

これで、テーブルの全列について絞り込みが出来ましたので、489行目「buf = SearchList」で、「絞り込んだデータ」を変数bufに代入しておいてから、491行目「Call TableFilterOFF」でテーブルの絞り込みを解除します。
変数bufは「絞り込んだデータ」が1列でもあれば、データの配列となりますが、絞り込んだ結果「1行も残らなかった」場合には「Empty」になっています。

493行目からはリストボックスの加工に入ります。
493行目「Me.ListBox1.Clear」で、一旦リストボックスのデータを全てクリアします。
494行目「If Not IsEmpty(buf) Then」では、「絞り込んだデータが1行以上ある(=Not Empty)」場合に495~500行目を実行し、ListBoxのリストを作成します。

495行目「For i = 1 To UBound(buf, 1)」では、カウンタ変数iを「絞り込んだデータの行数」分だけ回します。今回のListBox1は複数列(8列)のリストですので、まず496行目「Me.ListBox1.AddItem ""」で「空のリスト行」を作成します。
その上で、497行目「For j = 0 To LastCol」でカウンタ変数jをテーブルの列数分を回します。ここでカウンタjは「ゼロ」から開始しています。ListBox1のゼロ列目は「テーブルの行相対位置」を入れるため、ゼロスタートとしています。
498行目「Me.ListBox1.List(i - 1, j) = buf(i, j)」で、実際にデータをリスト行に書き込んでいきます。

以上でListBox1のリストが完成です。なお、絞り込んだ結果1行も残らなかった場合でも493行目「Me.ListBox1.Clear」でリストのクリアだけは済ませていますので、空のリストボックスとなります。

完成したリストボックスをクリックした時に呼び出されるのが、図5-27です。
  1. '========== ⇩(36) リスト内の1行を選択 ============
  2. Private Sub ListBox1_Click()
  3.   With Me.ListBox1
  4.    Me.TextBox3.Text = .List(.ListIndex, 1)   '←DvdNo
  5.    Me.TextBox1.Text = .List(.ListIndex, 2)   '←タイトル
  6.    Me.ComboBox1.Text = .List(.ListIndex, 3)   '←シリーズ
  7.    Me.TextBox2.Text = .List(.ListIndex, 4)   '←作成日
  8.    Call Bit2CKB(CLng(.List(.ListIndex, 5)))   '←CheckBoxのON-OFF
  9.    Me.ComboBox2.Text = .List(.ListIndex, 7)   '←保管場所
  10.   End With
  11.   Me.TextBox3.Enabled = False
  12. End Sub
図5-27

下段のリストボックス内の項目を選択した場合、その項目を上段の各項目に反映する必要があります。選択したリストボックスのデータには、図5-28のように、非表示のリスト列も含めて「全項目の情報」が収まっています。
そのデータを上段にデータ貼り付けをします。
リストボックスの項目選択時の動き
図5-28

511~516行目で、リストボックスデータを上段の各項目に貼り付けています。コードは以下のようになります。
項目コード内容コード番号
DvdNoMe.TextBox3.Text = .List(.ListIndex, 1)511行目
タイトルMe.TextBox1.Text = .List(.ListIndex, 2)512行目
シリーズMe.ComboBox1.Text = .List(.ListIndex, 3)513行目
作成日Me.TextBox2.Text = .List(.ListIndex, 4)514行目
保管場所Me.ComboBox2.Text = .List(.ListIndex, 7)516行目

残りは、ジャンル用のCheckBoxです。
CheckBoxの状態を数値に変換するには図5-24のように、各CheckBoxのON-OFFを1とゼロに置き換えて2進数の並びにしました。今度は逆に、ジャンルの数値を2進数にし、1となっている桁に相当するCheckBoxをONにすれば、ジャンルが再現できます。
それを行っているのが、図5-34の「Bit2CKBプロシージャ」で、515行目「Call Bit2CKB(CLng(.List(.ListIndex, 5)))」で実行しています。Bit2CKBの引数には「.List(.ListIndex, 5)」とリストボックスデータの5列目の値を渡していますが、Bit2CKB側がLong型として引数を受けなければいけないので、CLng関数でLong型に型変換しています。

最後に、519行目「Me.TextBox3.Enabled = False」で、DVD番号欄を編集不可状態にしています。これは「リストボックスの項目を選択中は、データ更新 または 削除」のため、「DVD番号を変更されないようにする」ためです。

次に今回システムでは、「リストボックスの項目を選択」した後、その項目を「ダブルクリック」した時には、「選択項目が新規登録扱い」になるようにしています。これは「データのコピー+修正」で、似たような内容のものなら手間を掛けずに登録できるようにするためです。
リストボックスをダブルクリックした時に呼び出されるのが、図5-29です。
  1. '========== ⇩(37) リストの項目をダブルクリック ============
  2. Private Sub ListBox1_DblClick(ByVal Cancel As MSForms.ReturnBoolean)
  3.  Me.ListBox1.ListIndex = -1
  4.  Me.TextBox3 = ""
  5.  Me.TextBox3.Enabled = True
  6. End Sub
図5-29

525行目「Me.ListBox1.ListIndex = -1」で、リストボックスの選択を解除(何も選択していない状態)しています。
526行目「Me.TextBox3 = ""」で、DVD番号欄を空欄にします。
527行目「Me.TextBox3.Enabled = True」で、DVD番号欄を編集可の状態にし、検索も可能な状態にしています。

5-2-10.チェックボックスの処理

システム起動時、図5-3の259行目から呼び出されるMakeCKBプロシージャが、図5-30です。
  1. '========== ⇩(38) チェックボックスの作成 ============
  2. Private Sub MakeCKB()
  3.  Dim i As Long      '←カウンタ変数(CheckBoxの数)
  4.  For i = 1 To UBound(Glist, 1)
  5.   Me.Controls("CheckBox" & i).Width = 80
  6.   Me.Controls("CheckBox" & i).Caption = Glist(i)
  7.   Me.Controls("CheckBox" & i).Value = False
  8.  Next i
  9. End Sub
図5-30

534行目「For i = 1 To UBound(Glist, 1)」では、カウンタ変数iをチェックボックスの個数だけまわします。配列Glistは、図4-1の8行目で宣言し、図4-3でデータ代入しています。
535行目「Me.Controls("CheckBox" & i).Width = 80」は、CheckBoxの幅を設定しています。この値は、フォーム上の配置・文字数などから決めていますので、改造などするときは調整して下さい。
536行目「Me.Controls("CheckBox" & i).Caption = Glist(i)」で、各CheckBoxのCaptionに文字を書き込んでいます。
537行目「Me.Controls("CheckBox" & i).Value = False」で、起動時には全てのCheckBoxをOFF状態にしています。なお、フォーム上へのコントロールの配置時、CheckBoxの既定はOFF(.Value=False)ですが、念のためこのコードを入れています。
537行目の代わりに、「Call Bit2CKB(0)」をFor~Nextの外に出して実行させてもOKです。

チェックボックスのON-OFF状態を数値に変換する「CKB2Bit関数プロシージャ」が、図5-31です。
  1. '========== ⇩(39) チェックボックスの状態を数値変換 ============
  2. Private Function CKB2Bit() As Long
  3.  Dim i As Long      '←カウンタ変数(CheckBoxの数)
  4.  Dim buf As Long     '←戻り値の一時変数
  5.  For i = 1 To UBound(Glist, 1)
  6.   If Me.Controls("CheckBox" & i).Value = True Then
  7.    buf = buf + (2 ^ (i - 1))
  8.   End If
  9.  Next i
  10.  CKB2Bit = buf
  11. End Function
図5-31

図5-24で、チェックボックスのON-OFF状態を1つの数値にする変換方法について説明しましたが、ここでは計算式としての変換の方法を、図5-32のように「チェックボックスの1・3・9にレ点(.Value = True)」が付いていた場合で考えます。
チェックボックスON-OFFを数値に変換
図5-32

図5-32の場合は、2進数で言うと「0001 0000 0101」となり、これを10進数に直すには、2進数の「2」に「1が立っている桁数-1 をベキ乗」し、それらを合計することで求まります。
つまり、1・3・9桁目に1があるのなら、「2^(-1)」+「2^(-1)」+「2^(-1)」となります。

この関係は、他の進数でも成立します。たとえば普段私たちが使っている10進数で考えると、「101」は「1・3桁目に1」がありますので、「10^(-1)」+「10^(-1)」となります。他に「203」の様な場合には、その桁の「数値」を各桁計算に掛けることになり、「×10^(-1)」+「×10^(-1)」となります。
2進数は「1かゼロしか無い」ので、各桁の計算に「1×」をしますが、それを省略しているだけです。

この考え方をコードにしたのが、547~551行目です。
547行目「For i = 1 To UBound(Glist, 1)」で、カウンタ変数iをCheckBox(=2進数の桁数)の数だけ回します。
548行目「If Me.Controls("CheckBox" & i).Value = True Then」で、そのCheckBoxのValue値がTrue(レ点が付いている)の場合に、549行目「buf = buf + (2 ^ (i - 1))」を実行します。
変数bufに、各桁ごとの数値の積算が完了した後、553行目「CKB2Bit = buf」で、CKB2Bit関数プロシージャの戻り値に設定します。なお、どの桁にも1が立っていなかったら、bufはLong型の初期値である「ゼロ」となります。

なお、Excelでは「True=-1、False=0」であることから、図5-33のようにも書くことができます。
  1. '========== ⇩(40) チェックボックスの状態を数値変換2 ============
  2. Private Function CKB2Bit2() As Long
  3.  Dim i As Long
  4.  Dim buf As Long
  5.  For i = 1 To UBound(Glist, 1)
  6.   buf = buf + Me.Controls("CheckBox" & i).Value * (2 ^ (i - 1))
  7.  Next i
  8.  CKB2Bit2 = -1 * buf
  9. End Function
図5-33

但しマイナス値として積算されていくため、565行目「CKB2Bit2 = -1 * buf」で、最後に「-1」を掛けて正数にしてから関数の戻り値に設定しています。
又は「buf = buf - Me.Controls("CheckBox" & i).Value * (2 ^ (i - 1))」とすれば、最後にマイナスをしなくてもOKです。

クリアボタンをクリックした時(図5-5 295行目)、リストボックスの項目をクリックした時(図5-27 515行目)、入力条件を復元する時(図5-38 618行目)に呼び出される「ジャンルデータ値をCheckBoxのON-OFFに変換」するBit2CKBプロシージャが図5-34です。
引数(b)として「ジャンルデータの数値」を受取ります。
  1. '========== ⇩(41) ジャンルデータをCheckBoxのON-OFFに変換 ============
  2. Private Sub Bit2CKB(b As Long)
  3.  Dim i As Long      '←カウンタ変数(CheckBoxの数)
  4.  For i = 1 To UBound(Glist, 1)
  5.   Select Case b And (2 ^ (i - 1))
  6.    Case Is > 0
  7.     Me.Controls("CheckBox" & i).Value = True
  8.    Case Else
  9.     Me.Controls("CheckBox" & i).Value = False
  10.   End Select
  11.  Next i
  12. End Sub
図5-34

571行目「For i = 1 To UBound(Glist, 1)」で、カウンタ変数iをチェックボックスの数(=2進数の桁数)だけ回します。
572行目「Select Case b And (2 ^ (i - 1))」では、引数b(ジャンルデータの数値)の「どのビットが立っているか」を調べます。
使われている関数は「And演算子」で、If文の条件式をつなぐ時に出てくるAndと同じものですが、整数値同士をつなぐと「ビットごとの演算」になります。
例えばこのプロシージャが引数として「261(10進数)」という整数値を渡されたとします。この261が572行目の「b」になります。一方で「2 ^ (i - 1)」は、カウンタ変数iによって変化する値です。iは1から始まり、チェックボックスの数(今回12個)まで増えていきます。この2つの値をAndでビット演算した時の結果が図5-35です。
ビット演算Andの結果
図5-35

「2 ^ (i - 1)」の値は、2進数で見ると「1が立った桁が1つずつ左側にズレていく」形になります。それに対して、引数bは固定値なので、引数bの2進数値の1が立っているところに来ると、その時だけAndの結果(最下段)が「ゼロ以外」になります。

このことを利用して、Andの結果がゼロ以外(573行目「Case Is > 0」)の時に、574行目「Me.Controls("CheckBox" & i).Value = True」で対象のチェックボックスをON(.Value = True)にし、それ以外(575行目「Case Else」)の時には、576行目「Me.Controls("CheckBox" & i).Value = False」でOFF(.Value = False)にします。

以上により、ジャンルデータの数値をチェックボックスのON-OFFに変換できます。

5-2-11.挿入データの配列化

ユーザーが入力・修正したデータを新規登録/更新する際に「テーブルへ書き込むデータの配列」を作るInData関数プロシージャが、図5-36です。
引数として、戻す配列の1列目「DVD番号」を受け取ります。それ以外は操作ダイアログ上段の各項目欄から読み取り、まとめて配列化をしています。
  1. '========== ⇩(42) 挿入データの配列化 ============
  2. Private Function InData(num As Variant) As Variant()
  3.  Dim buf As Variant      '←戻り値の一時配列
  4.  ReDim buf(1 To LastCol)
  5.  buf(1) = num
  6.  buf(2) = Trim(Me.TextBox1.Text)
  7.  buf(3) = Trim(Me.ComboBox1.Text)
  8.  buf(4) = Me.TextBox2.Text
  9.  buf(5) = CKB2Bit
  10.  buf(6) = "=BITAND([@Genre]," & ThisWorkbook.Sheets(ShName).Range(SearchGenre).Address & ")"
  11.  buf(7) = Trim(Me.ComboBox2.Text)
  12.  InData = buf
  13. End Function
図5-36

586行目「ReDim buf(1 To LastCol)」では、戻す配列のサイズを「テーブルの列数」に変形させてます。
588~594行目では、下記表のように配列bufに値を代入しています。
項目コード内容コード番号
1DvdNobuf(1) = num588行目
2タイトルbuf(2) = Trim(Me.TextBox1.Text)589行目
3シリーズbuf(3) = Trim(Me.ComboBox1.Text)590行目
4作成日buf(4) = Me.TextBox2.Text591行目
5ジャンルbuf(5) = CKB2Bit592行目
6ジャンル計算式buf(6) =
"=BITAND([@Genre]," & ThisWorkbook.Sheets(ShName).Range(SearchGenre).Address & ")"
593行目
7保管場所buf(7) = Trim(Me.ComboBox2.Text)594行目

この中で1列目の「buf(1) = num」は、引数として渡されたnumをそのまま配列の1番目にDVD番号として代入しています。
また5列目の「buf(5) = CKB2Bit」は、現在のCheckBoxの状態を数値変換した「CKB2Bit関数の戻り値」を代入します。

6列目は、テーブルのGbit列に入れる数式を文字列として代入しています。BITAND関数の第二引数の場所には「ThisWorkbook.Sheets(ShName).Range(SearchGenre).Address」を入れていますが、これは定数SearchGenre("J1")のアドレスとなります。ここでは.Addressの後ろに何も設定していないので「$J$1」と$を行側・列側の両方に付けた「絶対アドレス」となります。
その他は、上段の各項目欄からデータを吸い上げています。

最後に596行目「InData = buf」で、配列bufを関数InDataの戻り値にしています。

5-2-12.検索条件の記憶・復元

検索ボタンをクリックした時(図5-4 281行目)、新規登録ボタンをクリックした時(図5-6 316行目)に呼び出される「操作ダイアログ上段の各項目値を一時記憶」するプロシージャが図5-37です。
  1. '========== ⇩(43) 検索条件の一時保存 ============
  2. Private Sub CondMemory()
  3.  SearchCond(1) = Trim(Me.TextBox3.Text)
  4.  SearchCond(2) = Trim(Me.TextBox1.Text)
  5.  SearchCond(3) = Trim(Me.ComboBox1.Text)
  6.  SearchCond(4) = Me.TextBox2.Text
  7.  SearchCond(5) = CKB2Bit
  8.  SearchCond(7) = Trim(Me.ComboBox2.Text)
  9. End Sub
図5-37

602~607行目で、以下のように各項目値を配列SearchCondに代入します。
項目コード内容コード番号
1DvdNoSearchCond(1) = Trim(Me.TextBox3.Text)602行目
2タイトルSearchCond(2) = Trim(Me.TextBox1.Text)603行目
3シリーズSearchCond(3) = Trim(Me.ComboBox1.Text)604行目
4作成日SearchCond(4) = Me.TextBox2.Text605行目
5ジャンルSearchCond(5) = CKB2Bit606行目
7保管場所SearchCond(7) = Trim(Me.ComboBox2.Text)607行目

5列目のジャンルは、上段CheckBoxのON-OFF状態をCKB2Bit関数(図5-31)で数値化し、戻り値をSearchCond(5)に代入します。
その他は、上段各項目の値を「不要な前後のスペースを取り除いて」から配列に代入します。

データ更新時(図5-6 333行目)、データ削除時(図5-8 364行目)に呼び出されるCondRestoreプロシージャが、図5-38です。
  1. '========== ⇩(44) 検索条件の復元 ============
  2. Private Sub CondRestore()
  3.  Me.TextBox3.Text = SearchCond(1)
  4.  Me.TextBox1.Text = SearchCond(2)
  5.  Me.ComboBox1.Text = SearchCond(3)
  6.  Me.TextBox2.Text = SearchCond(4)
  7.  Call Bit2CKB(CLng(SearchCond(5)))
  8.  Me.ComboBox2.Text = SearchCond(7)
  9. End Sub
図5-38

614~619行目で、一時記憶した配列SearchCondからダイアログ上段の各項目に値を書き込みます。
項目コード内容コード番号
1DvdNoMe.TextBox3.Text = SearchCond(1)614行目
2タイトルMe.TextBox1.Text = SearchCond(2)615行目
3シリーズMe.ComboBox1.Text = SearchCond(3)616行目
4作成日Me.TextBox2.Text = SearchCond(4)617行目
5ジャンルCall Bit2CKB(CLng(SearchCond(5)))618行目
7保管場所Me.ComboBox2.Text = SearchCond(7)619行目

なおジャンルの数値SearchCond(5)は、Bit2CKBプロシージャ(図5-34)を使って、CheckBoxのON-OFFを復元しています。その際「CLng(SearchCond(5))」としていますが、これはSearchCondはVariant型で宣言(図4-1 7行目)されており、Bit2CKBプロシージャの引数のLong型(図5-34 568行目)と合っていないため、型変換をしています。

寄り道
データの入力や検索については、いくつか気になる点が残っています。

1つ目は、タイトルやシリーズ、保管場所に「数値」で登録される可能性があることです。例えば、ジェームズボンドの「007」や竹中直人の「119」、小説でも「1984」のようなタイトルのものがあるようです。
この数字を今回システムに登録すると、もちろんセル内データですから「数値として保存」されます。一番困るのが「007」は「7」となってしまう事です。

また、例えばタイトルとして「119」や「110」のように数値として保存されているデータに対し、部分一致を期待して「11」で検索しても「数値を『*11*』の文字列で検索」することになり抽出してくれません。それどころか「119」とフルで検索しようとしても、部分一致のままでは「数値を『*119*』の文字列で検索」することになり、やはり抽出できません。
ただし、MakeListBoxの絞り込みコード(図5-22 478行目)を「Call TableFilter(2, Trim(Me.TextBox1.Text), "*")」と赤字部分の「*(アスタリスク)」を無くし部分一致→完全一致にすれば、フルワード検索で抽出してくれます。

そこで、テーブルへの貼付けデータとして「数値」が来たら「文字列」に変えてから貼り付ける というのが図5-39です。
赤字の部分が、オリジナル(図5-36)と異なります。
  1. '========== ⇩(45) 挿入データの文字列化 ============
  2. Private Function InData2(num As Variant) As Variant()
  3.  Dim buf As Variant
  4.  Dim numTxt As Variant    '←入力文字列の一時保管
  5.  ReDim buf(1 To LastCol)
  6.  buf(1) = num
  7.  numTxt = Trim(Me.TextBox1.Text)
  8.  If IsNumeric(numTxt) Then
  9.   buf(2) = " ' " & numTxt
  10.  Else
  11.   buf(2) = numTxt
  12.  End If
  13.  numTxt = Trim(Me.ComboBox1.Text)
  14.  If IsNumeric(numTxt) Then
  15.   buf(3) = " ' " & numTxt
  16.  Else
  17.   buf(3) = numTxt
  18.  End If
  19.  buf(4) = Me.TextBox2.Text
  20.  buf(5) = CKB2Bit
  21.  buf(6) = "=BITAND([@Genre]," & ThisWorkbook.Sheets(ShName).Range(SearchGenre).Address & ")"
  22.  numTxt = Trim(Me.ComboBox2.Text)
  23.  If IsNumeric(numTxt) Then
  24.   buf(7) = " ' " & numTxt
  25.  Else
  26.   buf(7) = numTxt
  27.  End If
  28.  InData2 = buf
  29. End Function
図5-39

代表でタイトル欄での処理(632~637行目)を説明します。
632行目「numTxt = Trim(Me.TextBox1.Text)」で、タイトル欄に入力された文字列を一旦変数numTxtに代入します。

633行目「If IsNumeric(numTxt) Then」では、その文字列が「数値か否か」を判断します。
数値である場合は634行目「buf(2) = "'" & numTxt」で、文字列の先頭に「’(アポストロフィ)」を追加し、セルに貼り付けても数値にならないようにします。その上で、配列bufに代入します。
一方、数値ではない(=文字列扱い)場合には636行目「buf(2) = numTxt」で、そのまま配列bufに代入します。

この方法により、図5-40のように「例え数値のタイトル・シリーズ・保管場所であっても、文字列として保存」され、検索に於いても「7」だけで「007」が抽出できるようになります。
テーブル上の文字列・数値の違い
図5-40

もし図5-39をInData関数に使うのであれば、元の図5-36のInDataプロシージャを削除し、図5-39のプロシージャ名をInDataに変更すると共に、657行目の「InData2 = buf」を「InData = buf」に変更して下さい。

2つ目は「シリーズ」での検索は「部分一致」で良いのか、という問題です。
例えば、テーブル内のシリーズとして「相棒1」「相棒10」「相棒11」が保存されているとします。現在のシリーズ欄の検索方法は、部分一致(図5-23)としていますので、「相棒1」で検索すると「相棒10」も「相棒11」も抽出されます。「相棒」だけで検索した時と出力は何ら変わりません。

そこで、コンボボックスの「リストに載っている項目を選択」した時には「完全一致」を、コンボボックスのテキスト欄に手入力したり、InputBoxを使ったりして「テーブルには存在しない項目」の時には「部分一致」をする方法を考えました。
改造するのはダイアログ下段のリストボックスを作るMakeListBoxプロシージャです。ロジックは、コンボボックスの絞り込みを行う時には、事前に「その検索文字列はテーブル内に存在するか」を確認し、
 ・存在する(=リスト一覧のどれかを選択していると判断)時には「完全一致」
 ・存在しない(=テキストボックス部への手入力か、InputBoxを使用しての入力 と判断)時には「部分一致」
での絞り込みを行います。

但し他の項目を絞り込んでいる途中での「選択文字列の存在確認」は、正しい絞り込みが出来ていない可能性があるため、「他の項目を絞り込む前に存在確認」をする事が必要です。これをコードにしたのが図5-41で、赤字部分が変更点です。
なおシリーズ欄のみで無く、保管場所欄についても同様の処理をしました。
  1. '========== ⇩(46) 完全一致~部分一致の自動切り替え ============
  2. Private Sub MakeListBox2()
  3.  Dim buf As Variant
  4.  Dim i As Long
  5.  Dim j As Long
  6.  Dim ckb As Long
  7.  Dim Combobuf1 As Variant    '←ComboBox1のテキストで絞り込んだデータ
  8.  Dim Combobuf2 As Variant    '←ComboBox2のテキストで絞り込んだデータ
  9.  Application.ScreenUpdating = False
  10.   If Not Trim(Me.ComboBox1.Text) = "" Then
  11.    Call TableFilterOFF
  12.    Call TableFilter(3, Trim(Me.ComboBox1.Text))
  13.    Combobuf1 = SearchList
  14.    Call TableFilterOFF
  15.   End If
  16.   If Not Trim(Me.ComboBox2.Text) = "" Then
  17.    Call TableFilterOFF
  18.    Call TableFilter(7, Trim(Me.ComboBox2.Text))
  19.    Combobuf2 = SearchList
  20.    Call TableFilterOFF
  21.   End If
  22.   Call TableFilterOFF
  23.   Call TableSort("DvdNo")
  24.   Call TableFilter(1, Me.TextBox3.Text)
  25.   Call TableFilter(2, Trim(Me.TextBox1.Text), "*")
  26.   If IsEmpty(Combobuf1) Then
  27.    Call TableFilter(3, Trim(Me.ComboBox1.Text), "*")
  28.   Else
  29.    Call TableFilter(3, Trim(Me.ComboBox1.Text))
  30.   End If
  31.   Call TableFilter(4, Me.TextBox2.Text)
  32.   If IsEmpty(Combobuf2) Then
  33.    Call TableFilter(7, Trim(Me.ComboBox2.Text), "*")
  34.   Else
  35.    Call TableFilter(7, Trim(Me.ComboBox2.Text))
  36.   End If
  37.   ckb = CKB2Bit
  38.   If Not ckb = 0 Then
  39.    ThisWorkbook.Sheets(ShName).Range(SearchGenre) = ckb
  40.    Call TableFilter(6, ">0")
  41.   End If
  42.   buf = SearchList
  43.   Call TableFilterOFF
  44.   Me.ListBox1.Clear
  45.   If Not IsEmpty(buf) Then
  46.    For i = 1 To UBound(buf, 1)
  47.     Me.ListBox1.AddItem ""
  48.     For j = 0 To LastCol
  49.      Me.ListBox1.List(i - 1, j) = buf(i, j)
  50.     Next j
  51.    Next i
  52.   End If
  53.  Application.ScreenUpdating = True
  54. End Sub
図5-41

代表として、シリーズのComboBox1で説明します(保管場所の場合はコントロールがCombBox2、列位置が7になります)。

まず、672行目「If Not Trim(Me.ComboBox1.Text) = "" Then」で、ComboBox1が空欄(含:スペースのみ)の場合は、673~676行目を実行しません。ですので、絞り込み結果を代入する配列Combobuf1はVariant型の初期値Emptyのままとなります。この絞り込みでは、絞り込み結果は重要では無く、抽出されたか否かの結果のみを使用します。

673行目「Call TableFilterOFF」で、念のため全列の絞り込みを解除します。
674行目「Call TableFilter(3, Trim(Me.ComboBox1.Text))」で、コンボボックス欄の文字列で「完全一致」の絞り込みを行います。この絞り込みで「抽出行が存在すれば、リストから選択した」と判断し、「抽出行が無ければ、絞り込みたい文字列を入力した」と判断することにしました。
675行目「Combobuf1 = SearchList」で、抽出したデータを変数Combobuf1に代入します。「抽出行が存在すれば、Combobuf1は配列」となり、「抽出行が無ければ、Combobuf1はEmpty」となります。
676行目「Call TableFilterOFF」で、全列の絞り込みを解除します。

672行目のIf文で仕訳けせずに、ComboBox1が空欄の場合も673~676行目を実行しても、結果は同じです。但し、ComboBox1が空欄の場合には、674行目で絞り込みされないため、全データがSearchListから戻ってきます。つまり全データを配列に代入する処理時間が発生してしまうため、672行目のIf文で仕訳けをしています。

抽出結果Combobuf1が得られたら、691行目「If IsEmpty(Combobuf1) Then」で、「変数Combobuf1がEmptyか否か」を調べます。ComboBox1がどのような状態ならEmptyとなるか等を図5-42に示します。
ComboBox1の状態変数Combobuf1検索方法
空欄orスペースのみEmpty(初期値)部分一致
リスト外の項目を手入力Empty(抽出無)部分一致
InputBoxを使い入力・選択Empty(抽出無)部分一致
既存リスト内から選択配列完全一致
図5-42

その結果、Emptyの場合は692行目「Call TableFilter(3, Trim(Me.ComboBox1.Text), "*")」を実行し、「*(アスタリスク)」の付いた「部分一致」の絞り込みをします。
一方Empty以外(ComboBox1の文字列で行が抽出された)の場合は、ComboBox1の既存のリストから選択されたと考え、694行目「Call TableFilter(3, Trim(Me.ComboBox1.Text))」で「完全一致」の絞り込みをします。

別な方法として、ComboBox.ListIndex = -1 を使うやり方を思いつかれた方もいると思いますが、今回システムではリストのゼロ番の「(新規)」を選択すれば、InputBoxを通じて「ComboBoxのリストに、新しい項目を追加」できてしまいますので、確実な方法ではありません。

以上、2つの改善内容を盛り込むことで、文字列と数値の混在への対応、検索精度の向上がある程度図れると思いますが、やはり重要なのは「どんな使い方をするのか」「番号体系をどうするか」を事前に詰めておくことだと思います。その要件定義の結果次第では、他にも修正・改善するところが多数出てきてもおかしくありません。

6.アドインとしてExcelにマクロを登録

このマクロをExcelのアドインに登録することで、Excel起動中であれば「DVD等の内容・保管場所等管理システム」を使用することが出来ます。アドイン方法については「年賀状リスト等の宛名検索と追記 アドイン登録」を参照下さい。
またアドイン登録した際の実行マクロは、図4-2の「DVDmgtStartプロシージャ」にして下さい。

7.最後に

今回初めてListObjectを使ってみて「データ範囲を相対的に扱えるのは結構便利」という印象です。コード的には、毎回「With ThisWorkbook.Sheets(ShName).ListObjects(TableName)」などと書く必要はありますが、レコード数も簡単に取得できますし、列位置も番号・列名の両方で扱えます。
最初、テーブルと言う言葉の印象から、データベースのテーブルっぽいものを想像していたのですが、まだ知識が浅いために「検索はフィルターの絞り込み」によるものですし、且つ「Areaが分かれた範囲のレコードを一発で取得する方法」は今のところ見つかっていません。また「他のテーブルとのリレーショナル(関連)な操作」については、Power Queryを使えばテーブル同士の結合は可能ですが、条件で絞り込んだデータを得るには大変そうです。

とは言え、今までよりもずっと楽にデータ操作が出来る気がします。私もテーブル操作にもう少し慣れるためと、データベース的に操作できる手法を発見するために、このようなテーブルを使ったシステムにもう少し取り組んでみたいと思います。

お詫びですが、テーブルの列数を固定値の「7」にする場合と「LastCol」で計算する場合とが混在してしまいました。改造時に1箇所だけ変更すればOKみたいのを思い浮かべながらコードを書きましたが、恐らく要件定義をしっかりと行い、列数も固めた上で全てのコードを見直した方が良いと思います。


DVD等の内容・保管場所等管理システム(it-063.xlsm)

セキュリティ向上を目的として「インターネット経由でダウンロードしたOfficeファイル(Excel等)のマクロは、既定でブロック」されるようにOfficeアプリケーションの既定動作が変更になりました。(2022年4月より切替開始)
解除の方法については「ダウンロードファイルのブロック解除方法」を参照下さい。