65.9K
CodeProject 正在变化。 阅读更多。
Home

强制自动完成

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.80/5 (3投票s)

2015年4月18日

CPOL

6分钟阅读

viewsIcon

11577

downloadIcon

266

从大量数据中进行高效且安全的选择

引言

为了从大量条目中选择一个项目而呈现数据,目前尚未得到充分解决。想象一个拥有 300 多个条目的组合框——简直是行不通的!
一种改进方法是使用具有自动完成功能的文本框,因为自动完成是一种易于使用的过滤器,可以非常快速地减少数据量。

但即使是自动完成也有两个缺点

  • 它不保证数据安全:用户可以忽略自动完成建议,随意输入任何他想要的垃圾信息。
  • 在大型数据列表中,用户仍然会迷失方向,不知道哪些键是有效的,可以继续将选择范围减少到人类可接受的范围。

强制自动完成的概念

  • 显示一个下拉列表供用户选择(标准的自动完成行为)
  • 确保用户只能输入有效数据。立即拒绝无效的按键
  • 提供下一个有效按键的预览
  • (可选)“WindForward”:只要只有一个按键有效,自动完成就可以自动输入它。这可以大大减少所需的按键次数,但也会让快速打字的用户感到困惑——所以使其成为可选功能

也许这些概念听起来并不是什么“哇!——改变世界”。但是,假设有一个提供这些概念(并且还提供数据绑定)的稳定且易于使用的标准控件——您认为还有人会再使用组合框吗?不,我们所知的组合框——可能/大概会因此消亡。

几种看待数据的方式

假设以下排序数据(一些德国城镇)

Genthin
Gentingen
Genzkow
Georgenberg
Georgensgmünd
Georgenthal
Georgsdorf
Georgsmühle
Geraberg
Gerabronn
Gerach

我将它们放入一个统一的网格中,并标记出一些重要的字符

897961/DataWithMarkups.png<图 1>

在这些标记点,用户可以做出决定,每个决定都会缩小后续选择选项的范围。

您可以将这些点理解为决策树的节点——让我画出可用的决策路径

897961/DecisionTree1.png<图 2>

此外,我还可以删除树中的冗余字符

897961/DecisionTree2.png<图 3>

很有趣,不是吗?

但对我来说,最方便的视图是可视化一个实际的样本选择:让我们选择“Georgensgmünd”,然后看看在几个步骤中,这些决策如何将选择选项减少到一个,也就是最终的选择

897961/OptionReduction1.png<图 4>

输入“G”后,可以选择 11 个城镇中的任何一个。但在这一级别上,标记为“决策节点”的只有这三个:“n o r”。
选择“o”将选择选项减少到 5 个,现在只有两个决策节点:“e s”。
选择“e”后,选择选项减少到 3 个,并且有“b s t”可用作为决策节点。
选择“s”完成选择——只剩下“Georgensgmünd”选项。

概念验证:示例应用程序

897961/SampleApp.gif<图 5>

我的实现离能够从 GUI 开发中取代组合框还有很长的路要走:它缺少数据绑定支持,ValidNextKeys 预览并非在所有地方都适用,并且它继承了标准自动完成中的一个 bug 和一些不理想的行为。事实上,它易于使用的,但是——我承认:尚未像组合框那样易于使用……
但尽管如此,我认为它已经在处理从大量数据中选择数据的实际应用程序中可以做得很好。
样本下载是一个类似邮政编码查找器的东西,它提供了大约 16000 个德国城镇(和城镇区域)供选择。

我的代码设计是一种“文本框行为”:您可以将其附加到文本框,提供数据,它将配置自动完成、强制功能,并通知有关 ValidNextKeys 和可选的 WindForward 功能。

内部,它只是让标准的自动完成完成其下拉列表和建议工作,并仅通过强制、显示可用字符等功能进行扩展。
换句话说:一个真正轻量级的解决方案——不到 200 行代码。

使用代码

Public Class frmOrtFinder

   Private WithEvents _Coercer As New CoercingAutoCompletion

   Public Sub New()
      InitializeComponent()
      FillDatasetFromFile(Path.GetFullPath("..\..\Data\GeoNamesDE.inf"))
      Dim data = OrteDts.Ort.Select(Function(rw) ", ".Between(rw.Name, rw.Bundesland, rw.PLZ))
      _Coercer.Textbox = TextBox1
      _Coercer.ReadData(data)
   End Sub

   Private Sub _Coercer_AvailableCharsChanged(sender As Object, e As EventArgs) Handles _Coercer.AvailableCharsChanged
      lbAvailable.Text = _Coercer.AvailableChars
   End Sub

   Private Sub _Coercer_InputDone(sender As Object, e As EventArgs) Handles _Coercer.InputDone
      'busyness-logic: search the in the Textbox specified Data-Record and select it in Datagridview
      Dim fields = TextBox1.Text.Split({", "}, StringSplitOptions.None)
      For i = 0 To bsOrt.Count - 1
         If DirectCast(bsOrt(i), DataRowView).Row.ItemArray.Cast(Of String).SequenceEqual(fields) Then
            bsOrt.Position = i
            grdOrt.FirstDisplayedScrollingRowIndex = i
            Return
         End If
      Next
      Throw New Exception("Record not found - this should never occur!")
   End Sub
   '...
  • 实例化一个强制器(第 3 行)
  • 设置其文本框(第 9 行)
  • 给它数据(第 10 行)
  • 处理其事件(第 13、17 行)

实现细节

我将“文本框行为”和“数据解析”的关注点分开了——首先看看文本框部分

Public Class CoercingAutoCompletion

   Public Event AvailableCharsChanged As EventHandler
   Public Event InputDone As EventHandler

   '...

   Public Property Textbox As TextBox
      Get
         Return _TextBox
      End Get
      Set(value As TextBox)
         If _TextBox Is value Then Return
         If _TextBox IsNot Nothing Then Throw New Exception("Textbox already set!")
         _TextBox = value
         _TextBox = _TextBox
         _TextBox.AutoCompleteMode = AutoCompleteMode.SuggestAppend
         _TextBox.AutoCompleteSource = AutoCompleteSource.CustomSource
         _TextBox.AutoCompleteCustomSource = _AutoTextSource
      End Set
   End Property

   '...

   'improvements for standard-autocompletion: coerce valid inputs, notify about available Chars, selectionstart-wind-forward if wanted
   Private Sub _TextBox_KeyUp(sender As Object, e As KeyEventArgs) Handles _TextBox.KeyUp
      With _TextBox
         Dim i = .SelectionStart
         Select Case e.KeyCode
            Case Keys.Up, Keys.Down ' .Up/.Down choose text from Autocompletion-DropDown, try keep previous SelectionStart
               i = Math.Min(i, _UnselectedText.Length)
               _DataParser.Parse(.Text.Substring(0, i))
               _DataParser.SelStartPropose = i
            Case Keys.Left, Keys.Right
               i = _UnselectedText.Length + e.KeyCode - 38
               _DataParser.Parse(.Text.Substring(0, i))
               If e.KeyCode = Keys.Left Then _DataParser.SelStartPropose = i
            Case Else
               _DataParser.Parse(.Text.Substring(0, i))
               If e.KeyCode = Keys.Back Then _DataParser.SelStartPropose = i
               If _DataParser.FirstDivergence < i Then
                  System.Media.SystemSounds.Hand.Play()
                  .Text = .Text.Substring(0, _DataParser.FirstDivergence)
               End If
         End Select
         ImproveSelection()  'improves the Selection done right before by standard-Autocompletion, raises AvailableCharsChanged
      End With
   End Sub

   'Ensures that the Textbox can't be left without valid providing valid Input
   Private Sub _TextBox_Leave(sender As Object, e As EventArgs) Handles _TextBox.Leave
      With _TextBox
         _DataParser.Parse(.Text)
         _TextBox.Text = _DataParser.FirstBestMatch
         RaiseEvent InputDone(Me, EventArgs.Empty)
      End With
   End Sub
   '...

主要它配置自动完成,然后观察 `Textbox_KeyUp` 事件。并且要区分向上/向下、向左/向右、退格键和一般的按键。

  • 向上/向下键用于指示标准自动完成从下拉列表中选择下一个/上一个条目。因此,文本需要重新解析,并尝试恢复之前的选择。
  • 左右键用于移动选择的起始位置,但在任何状态下,自动完成选择都必须到达文本的末尾:这就是建议的含义:当用户输入任何其他内容时,它会立即消失。
  • 退格键等同于光标左移键,但自动完成会移除其建议(而不是重新建议更好的——我对此无能为力)。
  • 其他按键也会导致重新解析,在无效输入时,文本会被缩短回有效状态(第 22 行)。

到目前为止——也许更有趣的是数据解析器——这个关注点

Private _RawWords As Object()

Public Sub Refill(autoSource As AutoCompleteStringCollection, texts As IEnumerable(Of String))
   autoSource.Clear()
   Dim txts = texts.ToArray
   Array.Sort(txts, StringComparer.OrdinalIgnoreCase)
   autoSource.AddRange(txts)
   Dim bf = BindingFlags.Instance Or BindingFlags.NonPublic
   Dim inf = GetType(AutoCompleteStringCollection).GetField("data", bf)
   Dim data = inf.GetValue(autoSource)
   inf = GetType(ArrayList).GetField("_items", bf)
   _RawWords.Be(inf.GetValue(data))
   Parse("")
End Sub

`Refill()` 填充 AutoCompleteCollection,然后通过反射对其进行破解,以访问底层的数据数组。这就是为什么我的解决方案不会比标准自动完成占用额外的内存。
请注意,我使用 `StringComparer.OrdinalIgnoreCase` 进行排序。在用 BinarySearch 搜索数据时也使用相同的比较器。

Private Function FindBestMatch(txt As String) As Integer
   Dim i = Array.BinarySearch(_RawWords, txt, StringComparer.OrdinalIgnoreCase)
   Return If(i < 0, Not i, i)
End Function

二分查找非常高效:例如,只需 20 次内部迭代,就可以在一个大约一百万个已排序的项目范围内找到一个项目。返回值很棘手:如果搜索找到匹配项,它将返回其位置,值为 >= 0。否则,二分查找会简单地返回 `-1`,而是返回一个负数——匹配项应该在的位置(也称为“插入位置”)的按位补码
这就是 `Dataparser` 需要知道的:输入的插入位置(尽管它不进行插入)。
假设输入是“geo”,然后回顾一下 <图 4>:`FindBestMatch()` 将会得出第 4 行,这是从哪里开始查找下一个决策节点的起始行。
然后,我们必须找到特定“选项范围”的结束行。
再次应用 `FindBestMatch()` 二分查找:但首先在输入“geo”后面附加 `Char.MaxValue`,以确保修改后的输入“geo^”将检索到选项范围后面的第一行——即第 9 行。
总而言之,我们发现“geo”的第一个“最佳匹配”在第 4 行——“Georgenberg”,最后一个最佳匹配在第 8 行——“Georgsmühle”。从这个“选项范围”中,我们必须收集定义下一级选择的“决策节点”。
但如何找到下一个可用决策节点的正确列?
没那么复杂:只需比较选项范围的第一行和最后一行。第一次出现差异的那个位置将给出我们正在寻找的 x 坐标——在示例中是第 6 列,因为“Georgenberg”和“Georgsmühle”在第 6 列开始分叉。
现在从 `firstBestmatchPosition` 循环到 `lastBestMatchPosition`,并收集第 6 列的不重复字符。它们是:“e s”——决策节点,以及“WindForward”强制自动完成的“可用字符”。

代码中的故事

'figure out _FirstMatchPos, _LastMatchPos, FirstbestMatch, FirstDivergence, _DecisionColumn, SelectionStart
Public Sub Parse(txt As String)
   _FirstMatchPos = FindBestMatch(txt) ' first cut - possibly incorrect, on invalid input
   FirstBestMatch = Words(_FirstMatchPos)
   FirstDivergence = GetDivergence(txt, FirstBestMatch, 0)
   If FirstDivergence < txt.Length AndAlso _FirstMatchPos > 0 Then 'signals invalid input
      'depending on the sort-order of the first invalid char FindBestMatch() may have retrieved the position **after** the option-range
      Dim div2 = GetDivergence(txt, Words(_FirstMatchPos - 1), 0)
      If div2 < FirstDivergence Then
         _LastMatchPos = FindBestMatch(txt.Substring(0, FirstDivergence) & Char.MaxValue) - 1
      Else
         '_FirstMatchPos actually incorrect, and contains the position (+1) that _LastMatchPos should have. Make up that fault
         _LastMatchPos = _FirstMatchPos - 1
         FirstDivergence = div2
         _FirstMatchPos = FindBestMatch(txt.Substring(0, FirstDivergence))
         FirstBestMatch = Words(_FirstMatchPos)
      End If
   Else
      _LastMatchPos = FindBestMatch(txt & Char.MaxValue) - 1 'Position of the last word which fits to FirstDivergence
   End If
   _DecisionColumn = GetDivergence(FirstBestMatch, Words(_LastMatchPos), FirstDivergence)
   'SelectionStart is proposal - can be changed by the caller
   SelectionStart = If(FirstDivergence >= txt.Length AndAlso WindForward, _DecisionColumn, FirstDivergence)
End Sub

Private Function GetDivergence(s0 As String, s1 As String, start As Integer) As Integer
   For i = start To s0.Length - 1
      If Char.ToUpper(s0(i)) <> Char.ToUpper(s1(i)) Then Return i
   Next
   Return s0.Length
End Function

在无效输入时会发生一个复杂情况:`FindBestmatch()` 将返回第一个最佳匹配或最后一个最佳匹配——这取决于无效字符的排序顺序。
假设无效输入“Geoo”:二分查找的插入位置是第 4 行,因为“Geoo”小于“Georgenberg”。
但另一个无效输入“Geot”将检索到第 9 行,因为“Geot”大于“Georgsdorf”。因此,对于无效输入,还需要额外检查该行是否是选项范围的第一行或最后一行。

结论

对我来说,引入一个特别棘手的文本框行为实现并不那么重要。我更看重的是普遍引入“强制-WindForward 自动完成”的概念——而不是我对数据结构或解释代码的尝试。

即使是我的实现,与我的幻想相比也相当糟糕
也许是一个 WPF 控件,当获得焦点时,有效按键会以某种工具提示的形式弹出,等等。
或者它们会在下拉列表中被标记出来。
或者——保持良好的老式组合框——为什么不扩展 ComboBox 来实现强制 WindForward 自动完成呢?
当然,也必须创建 DataGrid 强制列……

致谢

  • 示例应用程序的数据来自 GeoNames,根据 CreativeCommons-License,这意味着:免费使用,只要我提及他们为作者——我乐意遵守。
© . All rights reserved.