1

(Using: Delphi XE)

I am adding a TButton to every row of a ListView. In the buttons OnClick handler is a Sender.Free. However (while the list row disappears because the dataset that populates the listview is updated,) the button remains on the listview when it should disappear. What am I doing wrong?

Here is my code that shows the creation of the button, and, the OnClick where it is to be freed:

(On another note, I know that its not good practice to destroy a component in its event handler. Is that what is wrong here? Can you suggest another method to remove the button?)

procedure TfMain.actWaitListExecute(Sender: TObject);
var
  li: TListItem;
  s:  string;
  btRect: TRect;
  p:  PInteger;
begin
  lstWaitList.Items.Clear;
  lstWaitList.Clear;

  with uqWaitList do
  begin
    if State = dsInactive then
      Open
    else
      Refresh;

    First;
    while not EOF do
    begin
      li := lstWaitList.Items.Add;
      s  := MyDateFormat(FieldByName('VisitDate').AsString);
      li.Caption := s;

      New(p);
      p^ := FieldByName('ROWID').AsInteger;
      li.Data := p;
      s  := MyTimeFormat(FieldByName('InTime').AsString);
      li.SubItems.Add(s);
      li.SubItems.Add(FieldByName('FirstName').AsString + ' ' +
        FieldByName('LastName').AsString);
      //  li.SubItems.Add(FieldByName('LastName').AsString);

      with TButton.Create(lstWaitList) do
      begin
        Parent  := lstWaitList;
        btRect  := li.DisplayRect(drBounds);
        btRect.Left := btRect.Left + lstWaitList.Column[0].Width +
          lstWaitList.Column[1].Width + lstWaitList.Column[2].Width;
        btRect.Right := btRect.Left + lstWaitList.Column[3].Width;
        BoundsRect := btRect;
        Caption := 'Check Out';
        OnClick := WaitingListCheckOutBtnClick;
      end;

      Next;
    end;
  end;


end;


procedure TfMain.lstWaitListDeletion(Sender: TObject; Item: TListItem);
begin
  Dispose(Item.Data);
end;

procedure TfMain.WaitingListCheckOutBtnClick(Sender: TObject);
var
  SelROWID, outtime: integer;
  x: longword;
  y: TPoint;

  h, mm, s, ms: word;

begin
  y := lstWaitList.ScreenToClient(Mouse.CursorPos);
  //  Label23.Caption := Format('%d %d', [y.X, y.y]);
  x := (y.y shl 16) + y.X;
  PostMessage(lstWaitList.Handle, WM_LBUTTONDOWN, 0, x);
  PostMessage(lstWaitList.Handle, WM_LBUTTONUP, 0, x);
  Application.ProcessMessages;

  SelROWID := integer(lstWaitList.Selected.Data^);
  //  ShowMessage(IntToStr(SelROWID));

  with TfCheckOut.Create(Application) do
  begin
    try
      if ShowModal = mrOk then
      begin
        decodetime(teTimeOut.Time, h, mm, s, ms);
        outtime := h * 100 + mm;

        uqSetOutTime.ParamByName('ROWID').Value := SelROWID;
        uqSetOutTime.ParamByName('OT').Value := outtime;
        uqSetOutTime.Prepare;
        uqSetOutTime.ExecSQL;

        (TButton(Sender)).Visible := False;
        (TButton(Sender)).Free;

        actWaitListExecute(Self);
      end;
    finally
      Free;
    end;
  end;

end;

Image:

enter image description here

Warren P
  • 65,725
  • 40
  • 181
  • 316
Steve F
  • 153
  • 2
  • 9
  • 1
    That's a lot of code you posted there, most of it irrelevant. Especially since you're clearly looking for the wrong thing. Start simplifying the code until you figure out the problem OR the problem goes away (if the problem goes away, take one step back and you found the actual problem). For example I'd start by only adding one button to the TListView and doing a `ShowMessage` from the OnClick handler. – Cosmin Prund Jul 08 '11 at 16:14
  • Also, why are you adding buttons to the TListView in the first place? That sounds like a very bad idea, because TListView itself isn't designed to "host" other controls; Even if that's not enough, you're relying on implementation details of the TListView to make your buttons look good. What happens if the TListView has wider margins on Windows8, or the header is wider, or thiner? – Cosmin Prund Jul 08 '11 at 16:16
  • Can you tell what are the 'PostMessage's (WM_LBUTTON[DOWN/UP]) supposed to do? Click the button again? – Sertac Akyuz Jul 08 '11 at 16:37
  • @SertacAkyuz: The PostMessage is supposed to click on the ListView so that a ListItem gets selected. Just clicking the button does not select the row (ListItem). Also note the ProcessMessages without which the next line does not 'see' a selected row. – Steve F Jul 08 '11 at 16:42
  • @Steve - Since the code is sensitive to selection you might want to include an Assigned() test. Anyway, I take it you're sure that the item with the clicked button is selected, otherwise you'd probably have an AV. – Sertac Akyuz Jul 08 '11 at 16:49
  • @Cosmin: As it is now, the button is using the SubItem's Left and Right property. I don't see how the listview's margins would have anything to do with distorting the way the button is drawn? – Steve F Jul 08 '11 at 16:50
  • Calling ProcessMessages inside OnClick?! What on earth are you thinking of? Call Release rather than Free. – David Heffernan Jul 08 '11 at 19:18
  • here's someone asking how to do it, and doing it in virtualtreeview, because TListView is not the right kind of control for this. http://stackoverflow.com/questions/2675094/delphi-listview-or-similar-with-owner-draw-button – Warren P Jul 08 '11 at 19:25
  • @David - I agree about ProcessMessages but Release is for TCustomForm only. OTOH, I wonder since it's about selecting an item, why the code just not select the item... – Sertac Akyuz Jul 08 '11 at 19:40
  • @sertac mea culpa. I guess you'd have to post yourself a message to free then. Design seems a bit odd though. Whenever I've used release I've always ended up moving away from it some point down the line.ve always ended up moving away from it some point down the line. – David Heffernan Jul 08 '11 at 19:45
  • This is how it looks if you owner-draw the buttons instead: http://privat.rejbrand.se/TListViewButton.png, http://privat.rejbrand.se/TListViewButtonClassic.png – Andreas Rejbrand Jul 08 '11 at 20:45
  • @Steve - Can you post a picture of the listview with the *unfreed button*, preferably while it has lesser rows then to fill the client height (like the one in the question right now). – Sertac Akyuz Jul 08 '11 at 20:51
  • @Sertac: This is what it looks like: http://img808.imageshack.us/img808/433/clipboard01hy.png – Steve F Jul 09 '11 at 08:29
  • @Steve - Thanks for the picture. I cannot duplicate the problem but I still think Application.ProcessMessages have got something to do with it. Can you try, when creating the button '(Button.)Tag:=Integer(li);' and in the checkout handler instead of 'PostMessage's and 'ProcessMessages' do 'TListItem(TButton(Sender).Tag).Selected:=True;' to select the correct item. – Sertac Akyuz Jul 09 '11 at 10:13
  • @Sertac: I tried what you mentioned and its the same. The button persists. – Steve F Jul 09 '11 at 13:59

4 Answers4

3

Well, I see two potential problems. First, you're using a with block, which could make the compiler resolve some identifiers differently than what you think they're supposed to resolve as. For example, if TfCheckOut has a member called Sender, you'll end up freeing that instead of the local Sender.

Second, the TButton(Sender).Free call is inside a conditional, and will only activate if that call to ShowModalis returningmrOK`. Have you gone into the debugger and made sure that that code branch is executing?

With regard to your question about not freeing a button inside its own event handler, it's perfectly legal, code-wise, to do so. It's not a good idea, and freeing it might cause an exception to be raised after the event handler completes, but it shouldn't do nothing, which is what you're seeing here. That almost certainly shows that Free is not being called at all. If you want a way to free it safely, take a look at messaging. You'll want to create a message ID and a handler for it on your form, then PostMessage (not SendMessage) that message to your form with the control as a parameter, and the message handler should free the button. That way you ensure that the event handler isn't running anymore.

EDIT: OK, so if you're sure that Free is being called, then Free is being called, and if Free finishes without raising an exception then the button is being destroyed. It's really that simple. (Try clicking on the button again after this code has run. Unless something very, very strange is going on, nothing will happen.) If you're still seeing the button afterwards, that's a different problem. It means that the parent (the TListView) is not repainting itself. Try calling its Invalidate method, which will make Windows repaint it properly.

Mason Wheeler
  • 82,511
  • 50
  • 270
  • 477
  • 1
    +1 for the troublesome `with` usage. I didn't have the patience to read all that code. – Cosmin Prund Jul 08 '11 at 16:21
  • @MasonWheeler: TfCheckOut is a form and does not have a member called Sender. Yes it is supposed to execute only if ShowModal returns mrOK ie when the user pressed OK in the form. I did make sure that the code is executing using the debugger. I'm stumped! – Steve F Jul 08 '11 at 16:46
  • @MasonWheeler: The PostMessage idea is interesting, could you give a code sample to demonstrate how this should be done? – Steve F Jul 08 '11 at 16:48
  • @MasonWheeler: I tried Invalidate, and it does not work. The problem is the button is being freed, but it is still visible and clickable on screen. But when you click it again it give an AV, but this is normal because the ListItem in which it was painted no longer exists, because the actWaitListExecute function is executed and the it clears all the listitems and adds them again, this listitem is missing because its row is no longer present in the source dataset. – Steve F Jul 08 '11 at 18:30
  • @MasonWheeler: Can I contact you direct and make the project source available somehow? If yes, then send an email to steve_goa AT yahoo DOT com. – Steve F Jul 08 '11 at 18:31
  • @Steve F: Then the problem isn't with the button. It sounds to me like the list item is getting deleted and still not being repainted. (Even if you call `Invalidate` on the TListView? Really? Then something's very wrong.) The repaint that isn't happening would have gotten rid of the image of the button. – Mason Wheeler Jul 08 '11 at 18:33
2

First of all, I have no idea why your solution doesn't work. All the pieces taken separately work fine, yet the combined solution doesn't work. Maybe the approach is overly-complicated and masks some issue, maybe it's one of those silly "I wrote i in stead of j" that you sometimes never see when looking at your own code...

Anyway, here's a quick implementation that does work. It doesn't take Raw data from a database, I used a TObjectList<> to store the data, but the concept is the same. To make it clear, I don't support the idea of putting buttons on a ListView, because the ListView wasn't designed to hold other controls. Just for fun, add enough raws to the list so vertical scroll-bars show up. Move the scrollbars down, your buttons do NOT move. Sure, you can hack something to work around the problem, but that doesn't change the root fact, it's a hack. What I'd do is switch to TVirtualTree, set it up to look like the list and draw the button column myself. Since the TVirtualTree control would be compiled into my executable, there's no chance of Windows upgrades braking my custom drawing.

PAS code:

unit Unit14;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, ComCtrls, Generics.Collections, StdCtrls;

type

  TItemInfo = class
  public
    DateAndTime: TDateTime;
    CustomerName: string;
  end;

  // Subclass the Button so we can add a bit more info to it, in order
  // to make updating the list-view easier.
  TMyButton = class(TButton)
  public
    ItemInfo: TItemInfo;
    ListItem: TListItem;
  end;

  TForm14 = class(TForm)
    ListView1: TListView;
    procedure FormCreate(Sender: TObject);
  private
    // Items list
    List: TObjectList<TitemInfo>;
    procedure FillListWithDummyData;
    procedure FillListView;
    procedure ClickOnCheckOut(Sender: TObject);
  public
    destructor Destroy;override;
  end;

var
  Form14: TForm14;

implementation

{$R *.dfm}

{ TForm14 }

procedure TForm14.ClickOnCheckOut(Sender: TObject);
var B: TMyButton;
    i: Integer;
    R: TRect;
begin
  B := Sender as TMyButton;
  // My button has a reference to the ListItem it sits on, use that
  // to remove the list item from the list view.
  ListView1.Items.Delete(B.ListItem.Index);
  // Not pretty but it works. Should be replaced with better code
  B.Free;
  // All buttons get there coordinates "fixed"
  for i:=0 to ListView1.ControlCount-1 do
    if ListView1.Controls[i] is TMyButton then
    begin
      B := TMyButton(ListView1.Controls[i]);
      if B.Visible then
      begin
        R := B.ListItem.DisplayRect(drBounds);
        R.Left := R.Right - ListView1.Columns[3].Width;
        B.BoundsRect := R;
      end;
    end;
end;

destructor TForm14.Destroy;
begin
  List.Free;
  inherited;
end;

procedure TForm14.FillListView;
var i:Integer;
    B:TMyButton;
    X:TItemInfo;
    ListItem: TListItem;
    R: TRect;
begin
  ListView1.Items.BeginUpdate;
  try
    // Make sure no Buttons are visible on ListView surface
    i := 0;
    while i < ListView1.ControlCount do
      if ListView1.Controls[i] is TMyButton then
        begin
          B := TMyButton(ListView1.Controls[i]);
          if B.Visible then
            begin
              // Make the button dissapear in two stages: On the first list refresh make it
              // invisible, on the second list refresh actually free it. This way we now for
              // sure we're not freeing the button from it's own OnClick handler.
              B.Visible := False;
              Inc(i);
            end
          else
            B.Free;
        end
      else
        Inc(i);
    // Clear the list-view
    ListView1.Items.Clear;
    // ReFill the list-view
    for X in List do
    begin
      ListItem := ListView1.Items.Add;
      ListItem.Caption := DateToStr(X.DateAndTime);
      Listitem.SubItems.Add(TimeToStr(X.DateAndTime));
      Listitem.SubItems.Add(X.CustomerName);

      B := TMyButton.Create(Self);
      R := ListItem.DisplayRect(drBounds);
      R.Left := R.Right - ListView1.Columns[3].Width;
      B.BoundsRect := R;
      B.Caption := 'CHECK OUT (' + IntToStr(R.Top) + ')';
      B.ItemInfo := x;
      B.ListItem := ListItem;
      B.OnClick := ClickOnCheckOut;
      B.Parent := ListView1;
    end;
  finally ListView1.Items.EndUpdate;
  end;
end;

procedure TForm14.FillListWithDummyData;
var X: TItemInfo;
begin
  X := TItemInfo.Create;
  X.DateAndTime := EncodeDate(2011, 7, 7) + EncodeTime(18, 6, 0, 0);
  X.CustomerName := 'Holmes Sherlok';
  List.Add(X);

  X := TItemInfo.Create;
  X.DateAndTime := EncodeDate(2011, 7, 7) + EncodeTime(18, 55, 0, 0);
  X.CustomerName := 'Glover Dan';
  List.Add(X);

  X := TItemInfo.Create;
  X.DateAndTime := EncodeDate(2011, 7, 8) + EncodeTime(23, 9, 0, 0);
  X.CustomerName := 'Cappas Shirley';
  List.Add(X);

  X := TItemInfo.Create;
  X.DateAndTime := EncodeDate(2011, 7, 8) + EncodeTime(23, 9, 0, 0);
  X.CustomerName := 'Jones Indiana';
  List.Add(X);
end;

procedure TForm14.FormCreate(Sender: TObject);
begin
  List := TObjectList<TitemInfo>.Create;
  FillListWithDummyData;
  FillListView;
end;

end.

DFM for the form; Those it's just a form with a ListView and an OnFormcreate, nothing fancy:

object Form14: TForm14
  Left = 0
  Top = 0
  Caption = 'Form14'
  ClientHeight = 337
  ClientWidth = 635
  Color = clBtnFace
  Font.Charset = DEFAULT_CHARSET
  Font.Color = clWindowText
  Font.Height = -11
  Font.Name = 'Tahoma'
  Font.Style = []
  OldCreateOrder = False
  OnCreate = FormCreate
  DesignSize = (
    635
    337)
  PixelsPerInch = 96
  TextHeight = 13
  object ListView1: TListView
    Left = 8
    Top = 8
    Width = 465
    Height = 321
    Anchors = [akLeft, akTop, akRight, akBottom]
    Columns = <
      item
        Caption = 'DATE'
        Width = 75
      end
      item
        Caption = 'IN TIME'
        Width = 75
      end
      item
        Caption = 'CUSTOMER NAME'
        Width = 150
      end
      item
        Caption = 'CHECK OUT'
        MaxWidth = 90
        MinWidth = 90
        Width = 90
      end>
    TabOrder = 0
    ViewStyle = vsReport
  end
end
Cosmin Prund
  • 25,498
  • 2
  • 60
  • 104
1

Instantiating a TButton dynamically in a TListview is the wrong approach.

First you need to understand that TListview is a wrapper for a Microsoft common control (ComCtl32), and that putting a TButton in there dynamically at runtime, is a poor hack. What would you do, for example, if the user resizes the form so that exactly 3.5 buttons should appear? how are you going to have the button clipped so that half of it is visible? Or would you make partial rows not have a visible button? Are you really sure you can handle all the strangeness that could happen when the user scrolls with the mouse wheel and you have to dynamically on the fly regenerate controls? You are not supposed to be generating controls and freeing them, in paint routines, or mouse down or up messages.

If you really want a button in there, what you need is two image states, an unpressed and pressed image, which you owner-draw in the correct location, when the correct cell is focused. And on a mouse down, in that area, you detect a click.

however, if you insist, then I would do this:

  1. Create the button or buttons once, dynamically, at the start of the program, and make each button visible or invisible as needed.
  2. Show or hide your button-or-button-control-array elements, instead of allocating them, hide instead of freeing, when you have too many buttons.

Your image shows one button per row, so let's assume you would need an array of about 30 buttons, created at runtime and stored in a control array (TList or Array of TButton)

A typical example of a grid with owner drawn buttons in each row, these buttons are drawn inside the cells, and mouse down handling causes the button to be drawn in the down state or up state, as needed:

enter image description here

But to draw each item, one row at a time, I would get some owner-draw-a-button code and paint a button in each cell.

The owner draw code:

// ExGridView1:TExGridView from https://sites.google.com/site/warrenpostma/
procedure TForm1.ExGridView1DrawCell(Sender: TObject; Cell: TExGridCell;
  var Rect: TRect; var DefaultDrawing: Boolean);
var
   btnRect:TRect;
   ofs:Integer;
   caption:String;
   tx,ty:Integer;
   Flags,Pressed: Integer;
   DC:HDC;
begin
 if Cell.Col = 1 then begin
    DC := GetWindowDC(ExGridView1.Handle);
    with ExGridView1.Canvas do
    begin
      Brush.Color := clWindow;
      Rectangle(Rect);
      caption := 'Button '+IntToStr(cell.Row);
      Pen.Width := 1;
      btnRect.Top := Rect.Top +4;
      btnRect.Bottom := Rect.Bottom -4;
      btnRect.Left := Rect.left+4;
      btnRect.Right := Rect.Right-4;
      Pen.Color := clDkGray;
      if FMouseDown=Cell.Row then
      begin
         Flags := BF_FLAT;
         Pressed := 1;
      end else begin
         Flags := 0;
         Pressed := 0;
      end;
      Brush.Color := clBtnFace;
      DrawEdge(DC, btnRect, EDGE_RAISED, BF_RECT or BF_MIDDLE or Flags);
      Flags := (btnRect.Right - btnRect.Left) div 2 - 1 + Pressed;
      PatBlt(DC, btnRect.Left + Flags, btnRect.Top + Flags, 2, 2, BLACKNESS);
      PatBlt(DC, btnRect.Left + Flags - 3, btnRect.Top + Flags, 2, 2, BLACKNESS);
      PatBlt(DC, btnRect.Left + Flags + 3, btnRect.Top + Flags, 2, 2, BLACKNESS);
      Font.Color := clBtnText;
      Font.Style := [fsBold];
      tx := btnRect.left + ((btnRect.Right-btnRect.Left) div 2) - (TextWidth(Caption) div 2);
      ty := btnRect.Top + 2;
      TextOut(tx,ty,caption);
    end;
    DefaultDrawing := false;
 end;
end;

There is other code, not shown above, to handle mouse down and mouse up, to figure out when a button is pressed. I can upload the full sample code somewhere if you want it.

Warren P
  • 65,725
  • 40
  • 181
  • 316
  • About not putting controls into a TListView - is there any documentation to support this? One button per row. See image of the listview in action here: http://img148.imageshack.us/img148/876/clipboard02oo.png – Steve F Jul 08 '11 at 19:13
  • Only my experience of having tried it and finding that it doesn't work properly, even when I subclass the button and write a helper class to intercept a bunch of mouse messages. David is right about Release versus free, but even then, I expect your solution will be flaky and not reliable. – Warren P Jul 08 '11 at 19:24
  • I agree with Warren. The best approach is to draw the button manually. Since the button resides inside a single cell, that should be very easy. – Andreas Rejbrand Jul 08 '11 at 19:39
  • This is how it looks if you owner-draw the buttons instead: http://privat.rejbrand.se/TListViewButton.png, http://privat.rejbrand.se/TListViewButtonClassic.png – Andreas Rejbrand Jul 08 '11 at 20:51
  • You know I really hate that the only way you can change a TListview's row height is by changing the ImageList size. Yuck! – Warren P Jul 08 '11 at 21:03
1

To All:

I solved the problem. Trying to Free the button in its OnClick handler was the problem. I read advice from many authors that this is plain bad practice. So I removed the Free call and keep track of the buttons in an ObjectList. And in actWaitListExecute, just Clear the objectlist, this clears all the buttons, and repaints new ones again.

In the Form declarations add:

  private
    { Private declarations }
    FButton : TButton;
    FButtonList : TObjectList;

In FormCreate add:

  FButtonList := TObjectList.Create;

Add FormDestroy:

procedure TfMain.FormDestroy(Sender: TObject);
begin
  FButtonList.Free;
end;

Modify actWaitListExecute to add the last line shown below:

procedure TfMain.actWaitListExecute(Sender: TObject);
var
  li: TListItem;
  s:  string;
  btRect: TRect;
  p:  PInteger;
begin
  lstWaitList.Items.Clear;
  lstWaitList.Clear;
  FButtonList.Clear;

also modify code in actWaitListExecute:

  FButton := TButton.Create(lstWaitList);
  FButtonList.Add(FButton);
  with  FButton do
  begin
    Parent := lstWaitList;
    Caption := 'Check Out';
    Tag := integer(li);
    OnClick := WaitingListCheckOutBtnClick;

    btRect := li.DisplayRect(drBounds);
    btRect.Left := btRect.Left + lstWaitList.Column[0].Width +
      lstWaitList.Column[1].Width + lstWaitList.Column[2].Width;
    btRect.Right := btRect.Left + lstWaitList.Column[3].Width;
    BoundsRect := btRect;
  end;

And everything works as expected..... a happy ending :)

Steve F
  • 153
  • 2
  • 9
  • What happens if you have more items than will fit on the screen and you have a scrollbar on your TListview? Does your code work with a listview that is scrolled by key-up/key-down, mouse wheel, and scrollbar thumb clicks? Does it all work properly? – Warren P Jul 09 '11 at 14:54
  • If you're still calling 'actWaitListExecute' from the check out handler, then you're still freeing the button(s) from 'WaitingListCheckOutBtnClick', and there is something else that made it work this time. Anyway, glad it works! – Sertac Akyuz Jul 09 '11 at 14:55
  • @Warren: It doesn't !!! When there are more rows than fit in the ListView and you have to scroll to the bottom, the last row has only the button drawn and blank area where there is supposed to be text. Thanks for asking me this, helping me find this inconsistency. Is this a bug with the VCL or with the COM control itself? – Steve F Jul 09 '11 at 15:12
  • @Warren: Screenshot: http://img811.imageshack.us/img811/6964/clipboard01ev.png How can this be solved? – Steve F Jul 09 '11 at 15:19
  • @Warren: It turns out that the last item in the list with the missing text is actually a dummy, and the button which is drawn (and clickable) in that last row is actually a repaint (or duplicate) of the button in the last visible row. I have really had it with ListView now. Can somebody please suggest the best component to use?? VirtualTree? StringGrid? DBGrid? – Steve F Jul 09 '11 at 15:58
  • ExGridView (see my answer) by Roman Mochalov, Virtual TreeView by Mike Lischke, or the VCL StringGrid with owner-draw. You should give up on TButton instances, and go with owner-drawn "button look and behaviour". – Warren P Jul 09 '11 at 19:13
  • @Warren: Thx. I will now try owner draw with a ExGridView. – Steve F Jul 10 '11 at 01:26