I'm making an application that uses a ListView control with MultiSelect = false. In some situations I need to prevent the user from changing the selected item. I thought it would be a simple task, but hours later I'm still trying to figure out what's going on.
So in order to have the option to "freeze" the ListView selection, I made a custom class CListView that inherits from ListView. If FreezeSelection is set to true, every time the users changes the selection, I'm trying to change it back:
public class CListView : ListView
{
public bool FreezeSelection { get; set; } = false;
bool _applyingSelectionUpdates = false;
protected override void OnSelectedIndexChanged(EventArgs e)
{
if (FreezeSelection)
{
if (_applyingSelectionUpdates)
return;
// for simplicity consider that the selected index while the selection is frozen is always 2
int selectedIndex = 2;
_applyingSelectionUpdates = true;
try
{
SelectedIndices.Clear();
if (selectedIndex >= 0)
SelectedIndices.Add(selectedIndex);
}
finally { _applyingSelectionUpdates = false; }
return;
}
base.OnSelectedIndexChanged(e);
}
}
The problem is when I set FreezeSelection back to false, and the user tries to select a different item. First of all, even if MultiSelect is false, visually it appears as there are two items selected. But programatically, when the user changes the selection, it seems there is sometimes the correct item selected, sometimes no item selected.
This behaviour is clearly a bug and I suspect what is causing this bug. When the user clicks on an item, the event SelectedIndexChanged is fired twice. Once after the SelectedIndices collection is cleared and the second time after the clicked item is added to the collection of selected items. I think the bug is caused by changing the selected items between these two events, but I need to know more about this. If MultiSelect is true and the user tries to select items with Ctrl, I have no problems.
To reproduce this bug you can use the following TestForm:
public class TestForm : Form
{
CListView listView;
CheckBox checkBox;
public TestForm()
{
listView = new() { Dock = DockStyle.Fill, View = View.Details, FullRowSelect = true, MultiSelect = false };
listView.Columns.Add("col 1");
listView.SelectedIndexChanged += ListView_SelectedIndexChanged;
Controls.Add(listView);
checkBox = new() { Dock = DockStyle.Right, Text = "freeze selection" };
checkBox.CheckedChanged += CheckBox_CheckedChanged;
Controls.Add(checkBox);
listView.Items.Add("item 1");
listView.Items.Add("item 2");
listView.Items.Add("item 3");
listView.Items.Add("item 4");
}
private void CheckBox_CheckedChanged(object? sender, EventArgs e)
{
listView.FreezeSelection = checkBox.Checked;
}
DateTime lastSelChangeTime = DateTime.MinValue;
private void ListView_SelectedIndexChanged(object? sender, EventArgs e)
{
if ((DateTime.Now - lastSelChangeTime).TotalMilliseconds > 200)
Debug.WriteLine(""); // this is just to group together what happens on a single user interaction
var indices = listView.SelectedIndices.Cast<int>().ToArray();
Debug.WriteLine("CListView fired selection changed event! "
+ DateTime.Now.ToString("h:m:s:fff") + " "
+ "{ " + string.Join(", ", indices) + " }");
lastSelChangeTime = DateTime.Now;
}
}
If you run this form:
Select the third item (with index 2)
Check "freeze selection"
Click on the forth item
Uncheck "freeze selection"
Try changing the selected item now and observe the bug
The question is how to solve this bug or how to achieve my initial goal (prevent users from selecting a different item).
Update:
To clarify, what I refered to as "a bug" is not the fact that I get two events for one selection change (I'm fine with that), it's the inconsistent behaviour between the UI and ListView.SelectedIndices after I "unfreeze" the selected index. I will demonstrate the problem with the following picture (note that each screenshot is taken after I clicked where the cursor is positioned; also the output window shows the SelectedIndices every time I get an SelectedIndexChanged event):
I use .NET 6.0.
As others have mentioned, there is no bug here as shown in this sequence of selecting Item 1, then Selecting Item2 (which first changes the selection by deselecting Item 1.
If you don't want the User to be selecting things during some arbitrary task (like waiting for a modified document to be saved), why not just set ListView.Enabled to false while you perform the work? In the testcode referenced below, I made an all-in-one for when the checkbox changes that sets the SelectionIndices collection to '2' as in your post;
There are now no issues going back to a state where freeze selection is unchecked and selecting some new item.
public TestForm()
{
InitializeComponent();
listView.MultiSelect = false;
listView.Columns.Add("col 1");
for (int i = 1; i <= 4; i++) listView.Items.Add($"Item {i}");
listView.SelectedIndexChanged += (sender, e) =>
{
richTextBox.AppendLine(
$"{DateTime.Now} : [{string.Join(", ", listView.SelectedIndices.Cast<int>())}]" );
var sel =
listView
.SelectedItems
.Cast<ListViewItem>();
if (sel.Any())
{
foreach (var item in sel)
{
richTextBox.AppendLine(item);
richTextBox.AppendLine();
}
}
else richTextBox.AppendLine("No selections", Color.Salmon);
};
checkBox.CheckedChanged += (sender, e) =>
{
listView.Enabled = !checkBox.Checked;
if (checkBox.Checked) doWork();
};
void doWork()
{
listView.SelectedIndices.Clear();
listView.SelectedIndices.Add(2);
}
}
Uses this extension for RichTextBox
static class Extensions
{
public static void AppendLine(this RichTextBox richTextBox) =>
richTextBox.AppendText($"{Environment.NewLine}");
public static void AppendLine(this RichTextBox richTextBox, object text) =>
richTextBox.AppendText($"{text}{Environment.NewLine}");
public static void AppendLine(this RichTextBox richTextBox, object text, Color color)
{
var colorB4 = richTextBox.SelectionColor;
richTextBox.SelectionColor = color;
richTextBox.AppendText($"{text}{Environment.NewLine}");
richTextBox.SelectionColor = colorB4;
}
}
Related
I am confronting with an issue with the ComboBox in a WinForms app. The combo box contains a list with items that are grabbed from a TXT file. To read and update the list I added the following code to Form_Load.
string[] lineOfContents = File.ReadAllLines(Application.StartupPath + #"Items.txt");
foreach(var line in lineOfContents)
{
string[] tokens = line.Split(',');
ComboBox.Items.Add(tokens[0]);
}
All nice and good, the list does update. However, I also have some TextBoxes that are changing their text based on the string of the selected item in the ComboBox. For example, when the selected item in the ComboBox is "Example", the text in the first text box should change from empty to "I am an example!", but it doesn't and remains empty. The code for it is:
if(ComboBox.SelectedItem == "Example")
{
TextBox.Text = "I am an example!";
}
At first I though it's a conversion issue so I tried to use Convert.ToString(tokens[0]); but it did not work as well.
Any suggestions on what I should try next to fix this issue?
You are describing bound behavior so the first thing to check is whether the TextBox is properly connected to the event fired by the ComboBox. Something like this would be the first step to getting the behavior you describe.
public MainForm()
{
InitializeComponent();
// Subscribe to the event here OR using the Designer
comboBox1.SelectedIndexChanged += comboBox1_SelectedIndexChanged;
}
private void comboBox1_SelectedIndexChanged(object sender, EventArgs e)
{
if (comboBox1.Text == "Example")
{
textBox1.Text = "I am an example!";
}
else textBox1.Text = comboBox1.Text;
}
But the missing step in the code you posted is actually selecting a value in the combo box after the tokens are loaded into it. This leaves the text box blank as demonstrated here:
private void buttonLoad_Click(object sender, EventArgs e)
{
loadMockFile();
}
private void loadMockFile()
{
string[] lineOfContents = MockFile;
foreach (var line in lineOfContents)
{
var token =
line.Split(',')
.Select(_ => _.Trim())
.Distinct()
.First();
if (!comboBox1.Items.Contains(token))
{
comboBox1.Items.Add(token);
}
}
}
string[] MockFile = new string []
{
"Example, A, B,",
"Other, C, D,",
};
So the solution is to make sure that you make a selection after the tokens are loaded into the ComboBox, for example as shown in the handler for the [Load + Select] button:
private void buttonLoadPlusSelect_Click(object sender, EventArgs e)
{
loadMockFile();
var foundIndex = comboBox1.FindStringExact("Example");
comboBox1.SelectedIndex = foundIndex;
}
I have tried a number of iterations to get this to work, and although I am close, I cannot seem to solve this.
private void cbxMealsNum_SelectedIndexChanged(object sender, EventArgs e)
{
int count = 0;
int cbxCurr = cbxMealsNum.SelectedIndex+1;
foreach (Control control in foodMeals.Controls.OfType<ComboBox>().Where(x => x.Name.Contains("MealsNo" + cbxMealsNum.Text)))
{
TextBox textBox = control as TextBox;
if (count < cbxCurr)
{
control.Enabled = true;
}
else
{
control.Enabled = false;
}
count++;
}
}
The above code allows me to Enable the correct combo box but none of the combo boxes before it. As such, if I select '6' in cbxMealsNum, meal 6 is enabled, but not the 5 preceding it.
Thus, I am asking how I would change the index for cbxMealsNum to, say, 4, and have only cbxMealsNo1 through to 4 enabled. If I then change cbxMealsNum to 3, cbxMealsNo4 should be disabled. If I change cbxMealsNum to 5, cbxMealsNo4 should be enabled once more, as should cbxMealsNo5.
I have tried a number of iterations of this code, including the following answer here as seen in the above sample, but to no avail. I am new to C# but I have been looking for every possible solution I can. I do not know if my search terms are malformed.
Please note, I have used the Where method as I intend to add textboxes and other controls contain the same naming convention (thus; cbxMealsNo1, txtMealsNo1, lblMealsNo1, and so on)
I am assuming you want all the combos enabled up to the selected number in the “number of meals” combo box. If this is the case then the code below may help.
First, it may be easier to put all the combo boxes into a collection since you will be needing them each time the “number of meals” combo box changes. It seems unnecessary to “re-collect” them each time. In the example below I created a simple array of six (6) combo boxes. We can then use that array to loop through all the combo boxes and enable the proper combo boxes.
ComboBox[] combos = new ComboBox[6];
public Form1() {
InitializeComponent();
combos[0] = cbxMealsNo1;
combos[1] = cbxMealsNo2;
combos[2] = cbxMealsNo3;
combos[3] = cbxMealsNo4;
combos[4] = cbxMealsNo5;
combos[5] = cbxMealsNo6;
}
Then in the “number of meals” selection changed event, a simple loop to enable all the combo boxes that are less than the selected number of meals. Something like….
private void cbxMealsNum_SelectedIndexChanged(object sender, EventArgs e) {
int cbxCurr = cbxMealsNum.SelectedIndex;
for (int i = 0; i < combos.Length; i++) {
if (i <= cbxCurr) {
combos[i].Enabled = true;
}
else {
combos[i].Enabled = false;
}
}
}
I have a listbox, with 2 buttons, new and delete. new adds an item into the list box, and the delete button should delete the item out of the list box. The list box items are tied to a class that stores user entered data from text boxes below.
private void AddListBox()
{
lstCondition.BeginUpdate();
Condition cond = new Condition("");
cond.Name = string.Format("Condition {0}", _selection.NetConditions.Count + 1);
_selection.NetConditions.Add(cond);
lstCondition.EndUpdate();
lstCondition.SelectedItem = cond;
cboNetCondition.Properties.Items.Clear();
cboNetCondition.Properties.Items.AddRange(NetCondition);
cboControlType.Properties.Items.Clear();
cboControlType.Properties.Items.AddRange(ControlType);
cboFlowRate.Properties.Items.Clear();
cboFlowRate.Properties.Items.AddRange(FlowRate);
}
private void btnNew_Click(object sender, EventArgs e)
{
AddListBox();
}
the cbo items are comboboxes, whose data gets tied in the condition class to each instance of the list box.
public frmNetConditions(Condition condo, Selection selection)
{
InitializeComponent();
_selection = selection;
lstCondition.DataSource = _selection.NetConditions;
condition = _selection.NetConditions.Count;
}
private void btnDelete_Click(object sender, EventArgs e)
{
selectedCondition = (Condition)lstCondition.SelectedItem;
cboControlType.SelectedIndex = -1;
cboNetCondition.SelectedIndex = -1;
cboFlowRate.SelectedIndex = -1;
txtFlowRate.Text = string.Empty;
txtStatPressure.Text = string.Empty;
txtDampOpening.Text = string.Empty;
txtDensity.Text = string.Empty;
cboDensity.SelectedIndex = -1;
lstCondition.Items.Remove(lstCondition.SelectedItem);
lstCondition.Refresh();
}
After pressing this delete button, the listbox, still contains the item i wish to delete, im unsure why thats the case?
Update with datasource
public List<Condition> NetConditions { get { return _netconditions; } }
As already suggested, you should bind to a BindingList<Condition> instead of a List<Condition>. This allows you to change the datasource and the control (ListBox) to get notified by your changes. The code should look like this:
lstCondition.ValueMember = "ConditionId";
lstCondition.DisplayMember = "Name";
lstCondition.DataSource = NetConditions;
After defining the binding, the correct way of operating on the ListBox items is to remove from the datasource, not the ListBox itself:
// SelectedItem should be checked for null (no selection is an option)
NetCondition.Remove((Condition)lstCondition.SelectedItem);
However, if you plan to change properties from an element (so, not the list itself), the control is notified only if your element (Condition) implements INotifyPropertyChanged interface.
I have a field that currently changes text on a mouse click. This field is on multiple pages of my report, but clicking it only changes the text for that page. Here is my code for that:
const string sShowDetail2 = "Show Cumulative";
const string sHideDetail2 = "Hide Cumulative";
ArrayList expandedValues2 = new ArrayList();
// This function returns a value indicating whether a certain
// category's details should be expanded.
bool ShouldShowDetail2(int CategoryID)
{
return expandedValues2.Contains(CategoryID);
}
private void lbShowHide2_BeforePrint(object sender, PrintEventArgs e)
{
XRLabel label = (XRLabel)sender;
if (ShouldShowDetail2((int)label.Tag))
{
label.Text = sHideDetail2;
}
else
{
label.Text = sShowDetail2;
}
}
private void lbShowHide2_PreviewClick(object sender, PreviewMouseEventArgs e)
{
// Obtain the category's ID stored in the label's Tag property.
int index = (int)e.Brick.Value;
// Determine whether the current category's details are shown.
bool showDetail2 = ShouldShowDetail2(index);
// Toggle the visibility of the category's details.
if (showDetail2)
{
expandedValues2.Remove(index);
}
else
{
expandedValues2.Add(index);
}
Now I have another field that I need to simply toggle all instances of the field(every page) on click. I do not need it to read the tag of the particular one I clicked because it will be changing all of them at the same time. My problem is that I don't know how to get them all to change on mouse click. How could I modify the code above to change all instances of the label?
Here is something you could try
private void ChangeXRLabels(Control control)
{
foreach(Control childControl in control.Controls)
{
XRLabel label = childControl as XRLabel;
if(label != string.Empty)
label.Text = "Your Text Value goes Here";
else if(childControl.Controls.Count > 0)
ChangeXRLabels(childControl);
}
}
Here is a short program that reproduces the problem I just encountered. This was compiled under MS Windows 7 with .NET 4.0, just in case that makes a difference.
using System;
using System.Drawing;
using System.Windows.Forms;
// Compile with "csc /target:exe /out:comboboxbug.exe /r:System.dll /r:System.Drawing.dll /r:System.Windows.Forms.dll comboboxbug.cs"
// in a Visual Studio command prompt.
static class Program
{
[STAThread]
static void Main()
{
//Create a label.
Label oLabel = new Label();
oLabel.Location = new Point (10, 10);
oLabel.Size = new Size (100, 15);
oLabel.Text = "Combo box bug:";
// Create a combo-box.
ComboBox oComboBox = new ComboBox();
oComboBox.Location = new Point (10, 50);
oComboBox.Size = new Size (150, 21);
oComboBox.Items.AddRange (new object[]
{ "A", "A B", "A C", "A B C", "A C B", "A B C D", "A C B D" });
oComboBox.AutoCompleteMode = AutoCompleteMode.SuggestAppend;
oComboBox.AutoCompleteSource = AutoCompleteSource.ListItems;
oComboBox.SelectionChangeCommitted
+= new EventHandler (comboBox_SelectionChangeCommitted);
// Create a form.
Form oForm = new Form();
oForm.Size = new Size (200, 150);
oForm.Controls.Add (oLabel);
oForm.Controls.Add (oComboBox);
// Run this form.
Application.Run (oForm);
}
static void comboBox_SelectionChangeCommitted (object sender,
EventArgs e)
{
MessageBox.Show ("SelectionChangeCommitted");
}
}
Click in the text portion of the combo-box and type "A". You will get a list of autocomplete suggestions. Click one of the selections with your mouse. The SelectionChangeCommitted event doesn't happen!
Select a menu-item without using autocomplete. You'll get a message-box showing that the SelectionChangeCommitted event happened!
Given that the selection was changed by the user in both cases, shouldn't SelectionChangeCommitted be called in both cases?
Using the SelectedIndexChanged event is not an option, because for the application behind this canned example, I only want it to happen when the user makes a selection, not when it's set programmatically.
EDIT 2020-Oct-28: I found another case where SelectionChangeCommitted doesn't get called! Auto-complete doesn't even need to be set for the problem to happen! Click to open the combo-box, press a key that matches the beginning of one of the combo-box items, and then press Tab to leave. The combo-box item gets selected, but SelectionChangeCommitted is not called! My revised answer is below.
Using the SelectedIndexChanged event is not an option, because for the application behind this canned example, I only want it to happen when the user makes a selection, not when it's set programmatically.
You could also accomplish this by writing a wrapper method for changing the selection that temporarily disables your event.
Unfortunately I do not know off hand a solution to the issue that SelectionChangeCommitted not being started for the more general case (such as where you don't control the ComboBox or how it is accessed).
EDIT:
I made a streamer of all the events that ComboBox calls, and it doesn't appear that any other event will do what you are looking for. The only solution I can think of would involve hooking into the events that the AutoComplete triggers. The difficulty is knowing what those events are, since they don't appear to trigger off the ComboBox from what my minor testing shows.
FYI, here was the best solution I ever came up with. Obviously, this is a Leave event-handler on a ComboBox subclass. The SelectionChangeCommitted event doesn't happen on the mouse-click, but at least it happens during the normal flow of GUI interaction.
private void this_Leave (object sender, EventArgs e)
{
// If this is an autocomplete combo-box, select the
// item that was found by autocomplete.
// This seems like something that ComboBox should be
// doing automatically...I wonder why it doesn't?
if (this.AutoCompleteMode != AutoCompleteMode.None)
{
// Determine which combo-box item matches the text.
// Since IndexOf() is case-sensitive, do our own
// search.
int iIndex = -1;
string strText = this.Text;
ComboBox.ObjectCollection lstItems = this.Items;
int iCount = lstItems.Count;
for (int i = 0; i < iCount; ++i)
{
string strItem = lstItems[i].ToString();
if (string.Compare (strText, strItem, true) == 0)
{
iIndex = i;
break;
}
}
// If there's a match, and this isn't already the
// selected item, make it the selected item.
//
// Force a selection-change-committed event, since
// the autocomplete was driven by the user.
if (iIndex >= 0
&& this.SelectedIndex != iIndex)
{
this.SelectedIndex = iIndex;
OnSelectionChangeCommitted (EventArgs.Empty);
}
}
}
If someone got this problem, I suggest a solution that works fine to me...
Think with me, to accept the suggest of Combo-box, generally the user needs to key down with an Enter key.
You can write into KeyDown event of Combo-box property, something like this:
private void cboProperty_SelectionChangeCommitted(object sender, EventArgs e)
{
//Call here the event of SelectionChangeCommitted
cboProperty_SelectionChangeCommitted(sender,null);
}
It will raise the SelectionChangeCommitted on the right time.
This workaround worked fine for me and hope for you too. When use Autocomplete by typing data in your combo box to get an item through keyboard or mouse selection you need _KeyDown event. From inside invoke _SelectionChangeCommitted method which contains code for other operations. See code below:
private void YourComboBox_KeyDown(object sender, KeyEventArgs e)
{
//Works also when user select and click on autocomplete list.
if (e.KeyCode == Keys.Enter && YourComboBox.SelectedItem != null)
YourComboBox_SelectionChangeCommitted(sender, e);
}
For the non-auto-complete case mentioned above (i.e. my 2020-Oct-28 edit), this Leave event-handler on a ComboBox subclass incorporates the new case as well as the old one, as long as your SelectionChangeCommitted event-handler is idempotent. Compared to my previous answer, it removes the test for auto-complete, and always calls OnSelectionChangeCommitted().
private void this_Leave (object sender, EventArgs e)
{
// Determine which combo-box item matches the text.
// Since IndexOf() is case-sensitive, do our own
// search.
int iIndex = -1;
string strText = this.Text;
ComboBox.ObjectCollection lstItems = this.Items;
int iCount = lstItems.Count;
for (int i = 0; i < iCount; ++i)
{
string strItem = lstItems[i].ToString();
if (string.Compare (strText, strItem, true) == 0)
{
iIndex = i;
break;
}
}
// If there's a match, and this isn't already the
// selected item, make it the selected item.
if (iIndex >= 0
&& this.SelectedIndex != iIndex)
this.SelectedIndex = iIndex;
// Force a selection-change-committed event, since
// the autocomplete was driven by the user.
OnSelectionChangeCommitted (EventArgs.Empty);
}