Forum Archive

Swipe TableViewCell to show multiple actions

colint

Hello,

I would like to have another action (in addition to the delete action) be available when swiping a TableViewCell to the side. From this older forum post it seems like I need to override a method, but I didn't get enough details from that to be able to do it myself (do I override in the TableView or in the delegate?). I also found this website explaining how to do it in Swift https://useyourloaf.com/blog/table-swipe-actions/#swipe-actions-ios-11, which shows a different and newer method to override since the older one is now deprecated. However, I'm not sure how to translate this code into Python, and I'm also not sure how to override the method since my delegate is not actually a subclass of UITableViewDelegate (and I'm not sure how to make it one). Any help would be greatly appreciated!

cvp

@colint try this script and swipe left a row

cvp

@colint There is some error or something missing in my code because, after you execute your own handler code, the swipe menu does not disappear.
Normally, you should end by setting an actionPerformed flag (see here) to perform or not the standard process (like a delete) and then reset the swipe. I've tried some modification but I get a segmentation error.

def handler(_action_performed):
    print('handler called')
    #action_performed = True
    return
.
.
.
        handler_block = ObjCBlock(handler, argtypes=[c_void_p])
        UIContextualAction.setHandler_(handler_block)

I think we need some help from our guru @JonB , thanks to him in advance

JonB

@cvp The handler gets called with 3 arguments, the last of which is a completion_handler. You must call the completion_handler with True or False, indicating whether the action was successful.

Somewhere we have an example of a handler like that -- let me search...

JonB

@JonB
https://forum.omz-software.com/topic/6402/dealing-with-a-completion-handler/6

This would have to be customized for this specific completion handler... Rubicon is a lot better for this.

JonB

@cvp
So, the handler has to have following signature

typedef void (^UIContextualActionHandler)(UIContextualAction *action, __kindof UIView *sourceView, void (^completionHandler)(BOOL actionPerformed));

Thus, we have 3 args: two ObjCInstance and a block which has one bool argument and no return type. These are all c_void_p, but the last one has to be treated as a block structure whose invoke field has the appropriate CFUNCTYPE, so we can call it.

Referring back to a time when I was smarter, I think the following should do it, in this case

# descriptor for completion handler that is sent to our handler
class _block_descriptor (Structure):
  _fields_ = [('reserved', c_ulong), ('size', c_ulong), ('copy_helper', c_void_p), ('dispose_helper', c_void_p), ('signature', c_char_p)]
InvokeFuncType = ctypes.CFUNCTYPE(None, *[c_void_p, ctypes.c_bool])
class _block_literal(Structure):
  _fields_ = [('isa', c_void_p), ('flags', c_int), ('reserved', c_int), ('invoke', InvokeFuncType), ('descriptor', _block_descriptor)]

def handler(_action, _sourceView, _comp):
    print(ObjCInstance (_action))
    blk=_block_literal.from_address(_comp)
    blk.invoke(comp,True)

handler_block=ObjCBlock(handler,restype=None,argtypes=[c_void_p,c_void_p, c_void_p])

cvp

@JonB thanks for your usual help, tried this but still segmentation error

# descriptor for completion handler that is sent to our handler
class _block_descriptor (Structure):
    _fields_ = [('reserved', c_ulong), ('size', c_ulong), ('copy_helper', c_void_p), ('dispose_helper', c_void_p), ('signature', c_char_p)]
InvokeFuncType = ctypes.CFUNCTYPE(None, *[c_void_p, ctypes.c_bool])
class _block_literal(Structure):
    _fields_ = [('isa', c_void_p), ('flags', c_int), ('reserved', c_int), ('invoke', InvokeFuncType), ('descriptor', _block_descriptor)]

def handler(_action, _sourceView, _comp):
    print(ObjCInstance (_action))
    blk=_block_literal.from_address(_comp)
    blk.invoke(comp,True)


@on_main_thread
def Main():
        global UISwipeActionsConfiguration
        root_vc = UIApplication.sharedApplication().keyWindow().rootViewController()
        tableviewcontroller = UITableView.alloc().initWithStyle_(UITableViewStyle.UITableViewStylePlain)

        #=============== TableView delegate: begin              
        #set delegate
        tb_ds = TVDataSourceAndDelegate.alloc().init().autorelease()
        tableviewcontroller.tableView().setDataSource_(tb_ds)

        tb_dl = UITableViewDelegate.alloc().init().autorelease()
        tableviewcontroller.tableView().setDelegate_(tb_dl)

        # set actions if swipe
        UIContextualAction = ObjCClass('UIContextualAction').alloc()

        UIContextualAction.setTitle_("@Ryubai's' action 😅")

        handler_block=ObjCBlock(handler,restype=None,argtypes=[c_void_p,c_void_p, c_void_p])

        UIContextualAction.setHandler_(handler_block)
        UIContextualAction.setBackgroundColor_(ObjCClass('UIColor').blueColor().colorWithAlphaComponent(0.5))

        UIContextualAction2 = ObjCClass('UIContextualAction').alloc()
        #UIContextualAction2.setStyle_(1)

        UIContextualAction2.setTitle_("my delete")
        # block does not have parameter nor return, thus we can use a Python def
        UIContextualAction2.setHandler_(handler2)
        UIContextualAction2.setBackgroundColor_(ObjCClass('UIColor').redColor().colorWithAlphaComponent(1.0))


        UISwipeActionsConfiguration = ObjCClass('UISwipeActionsConfiguration').configurationWithActions_([UIContextualAction, UIContextualAction2])
        #=============== TableView delegate: end

        root_vc.presentViewController_animated_completion_(tableviewcontroller, True, None)
JonB

What is handler2? Also, maybe we need to retain_global, since you define the block inside a function.

cvp

@JonB hanler2 for delete tab but not yet modified nor used...I did not tap delete

cvp

@JonB ok, I move the block outside main(), no more segmentation error, but

Traceback (most recent call last):
  File "_ctypes/callbacks.c", line 234, in 'calling callback function'
  File "/private/var/mobile/Containers/Shared/AppGroup/1B829014-77B3-4446-9B65-034BDDC46F49/Pythonista3/Documents/MesTests/UITableViewController user swipe.py", line 132, in handler
    blk.invoke(comp,True)
NameError: name 'comp' is not defined
JonB

@cvp ok, removing the blk.invoke doesn't crash... Maybe we have the block signature wrong.

JonB

Ahh, by printing arguments, I realized that I forgot that blocks have hidden arguments pointing to themselves.


class _block_descriptor (Structure):
    _fields_ = [('reserved', c_ulong), ('size', c_ulong), ('copy_helper', c_void_p), ('dispose_helper', c_void_p), ('signature', c_char_p)]
InvokeFuncType = ctypes.CFUNCTYPE(None, *[c_void_p, ctypes.c_bool])
class _block_literal(Structure):
    _fields_ = [('isa', c_void_p), ('flags', c_int), ('reserved', c_int), ('invoke', InvokeFuncType), ('descriptor', _block_descriptor)]

def handler(_blk, _action, _sourceView, _comp):
    print(ObjCInstance (_action))
    print(ObjCInstance(_sourceView))
    print(ObjCInstance(_comp))
    blk=_block_literal.from_address(_comp)
    print(blk.descriptor.signature)
    blk.invoke(_comp, True)
handler_block=ObjCBlock(handler,restype=None,argtypes=[c_void_p, c_void_p,c_void_p, c_void_p])
JonB

…. But now I get a crash whenever I press enter at the console again. I think something in the VC needs to be retained?

cvp

@JonB With new code, I get

Traceback (most recent call last):
  File "_ctypes/callbacks.c", line 234, in 'calling callback function'
TypeError: handler() missing 1 required positional argument: '_comp'
cvp

@JonB my error, I forgot to add a c_void_p

No more segmentation error

Log is

<UIContextualAction: 0x2856e4e60: style=0, title=@Ryubai's' action 😅, backgroundColor=UIExtendedSRGBColorSpace 0 0 1 0.5>
<UISwipeActionStandardButton: 0x115808080; frame = (81 0; 120 43.5); anchorPoint = (0, 0.5); opaque = NO; autoresize = W+H; tintColor = UIExtendedGrayColorSpace 1 1; layer = <CALayer: 0x28360d860>>
<__NSStackBlock__: 0x16bb0c608>
b'5\xf0\xb2\xdc\x01'
JonB

Strange... I don't get a crash right away, but if I close the VC and type anything in the console, I get a seg fault. I moved everything into the main code, deleted the auto releases, and tried dismissing the VC from within the handler, but same result. Maybe I broke something else elsewhere.

Well ... If it works, good! Note you can use the action argument's title property to figure out which button was tapped, if you don't want to have a different handler for each button.

cvp

@JonB If I start Pythonista and I run the script a first time, the 2nd button is absent. Then, I close the program and relaunch it, the 2nd button appears. That's also strange

And if I relaunch it a third time, I have a segmentation error

cvp

@JonB said

Referring back to a time when I was smarter

I can't believe that, smarter than now....Is that really possible 😉

totstiny89

I have the same issue. Any tips for solution? vidmate
mobdro

JonB

One answer might be to simple remove the blk.invoke line.

I will experiment a bit more this weekend

colint

Thanks for all the help! And sorry for taking a while to get back to you, I’ve been really busy these past few days. I tried your code and both actions are showing up for me every time, however Pythonista is still crashing from segmentation faults whenever I tap either action (whether or not I remove the blk.invoke). Thanks again and I agree that it’s hard to imagine a time when @JonB was smarter!

cvp

@colint could you try this little script which does not use ObjectiveC but marvelous gestures module of @mikael

Swipe left a row and test "delete" and "yours edit" buttons

It is not perfect, for instance, you can swipe left several rows, but this could be protected easily by some extra lines of code. Only to show what is possible without ObjectiveC

You could even replace the swipe by a tap or a double tap on the row.

import console
import ui
import gestures

class MyTableView(ui.View):

    def __init__(self,w,h):
        self.width = w
        self.height = h

        tbl = ui.TableView()    
        tbl.frame = (0,0,w,h)
        tbl.row_height = 50
        tbl.data_source = ui.ListDataSource(items=['a','b','c','d','e'])
        tbl.delegate = self
        tbl.data_source.tableview_cell_for_row = self.tableview_cell_for_row
        tbl.data_source.tableview_can_delete = self.tableview_can_delete
        tbl.background_color = (0,0,0,0)
        self.add_subview(tbl)

    def but_action(self,sender):
        v = sender.superview
        tbl = v.tv
        row = v.row
        for sv in v.subviews:
            v.remove_subview(sv)
        if sender.title == 'delete':            
            b = console.alert('delete row',str(row), 'confirm', 'cancel', hide_cancel_button=True)
            if b == 1:
                del tbl.data_source.items[row]
                tbl.reload()
        elif sender.title == 'yours edit':
            t = tbl.data_source.items[row]
            t = console.input_alert('enter text of row', str(row), t, 'ok', hide_cancel_button=True)
            tbl.data_source.items[row] = t
            v.text_label.text = t

    def LEFT_swipe(self,data):
        v = data.view
        w = v.width
        bdel = ui.Button()
        bdel.frame = (w,0,w/4,v.height)
        bdel.background_color = 'red'
        bdel.tint_color = 'white'
        bdel.title = 'delete'
        bdel.action = self.but_action
        v.add_subview(bdel)
        byou = ui.Button()
        byou.frame = (bdel.x+bdel.width,0,w/4,v.height)
        byou.background_color = 'green'
        byou.tint_color = 'white'
        byou.title = 'yours edit'
        byou.action = self.but_action
        v.add_subview(byou)
        def animation():
            bdel.x = w/2
            byou.x = bdel.x + bdel.width
        ui.animate(animation, duration=0.3)

    def RIGHT_swipe(self,data):
        v = data.view
        for sv in v.subviews:
            v.remove_subview(sv)

    def tableview_cell_for_row(self, tableview, section, row):
        # Create and return a cell for the given section/row
        cell = ui.TableViewCell()
        cell.tv = tableview
        cell.row = row
        cell.selectable = False
        cell.text_label.text = tableview.data_source.items[row]
        gestures.swipe(cell,self.LEFT_swipe, direction=gestures.LEFT)
        gestures.swipe(cell,self.RIGHT_swipe, direction=gestures.RIGHT)
        return cell

    def tableview_can_delete(self, tableview, section, row):
        # Return True if the user should be able to delete the given row.
        return False    # to be sure that standard delete button is not displayed

def main():

    # Hide script
    w,h = ui.get_screen_size()
    mi = min(w,h)*0.9
    my_back = MyTableView(mi,mi)
    my_back.background_color='white'
    my_back.name = 'Test for @colint'   

    my_back.present('sheet',hide_title_bar=False)   
    my_back.wait_modal()

# Protect against import    
if __name__ == '__main__':
    main()
cvp

@colint feed-back hoped, please

colint

@cvp Thanks for the script, the gestures module seems really neat! I tried out your code and the actions showed up nicely, the only thing is that for some reason, the script freezes when I press either of the buttons. I have had some issues with console.alert that I think started after some iOS update, so it may just be that that is causing the problem.

cvp

@colint strange, I don't have any freeze. I know that sometimes console.alert needs to be decorated by @ui.in_background, but here, I don't have the problem. Anyway, the challenge was to have several buttons when swiping, isn'it?

colint

Strange, it must be just a problem on my end then. When I put ui.inbackground it doesn’t freeze, but the alerts show up behind the presented view, so I can only see them once I close the window. Anyway, like you said, as long as both actions show up then this is a good alternative way to do it! The only thing I’m wondering about now is if there is a way to get the same behaviour from the original solution, where a long swipe to the left would perform one of the actions. Thanks again for the help!

cvp

@colint said

long swipe

In the gestures module, the swipe function has min_distance and max_distance but they do not seem to work or you can't have two different swipes in the same direction.
Anyway, these parameters seem to belong to a private API, I think, and so it would be not allowed to be used.

I try to get the width of the swipe gesture and to process differently for a short or a long swipe but, actually, without success.

Edit : I think that should be possible without using gestures but the standard touch_began/moved/ended methods where you can continuously compare finger movement from its begin to deduce if the swipe is short or long.
I could try but not this Sunday, normally tomorrow

colint

I could try but not this Sunday, normally tomorrow

Of course, no rush! That’s interesting, I didn’t know you could use touch_began, etc. with the ui module. I suppose it would need a little math to calculate which cell the touch was on but that should still be doable.

cvp

@colint said

would need a little math to calculate which cell the touch was

Not needed, try this draft of solution, not yet finished, very far of the end, only to let you know how it could work. I did not want to send something so early, only to show something you can try.
Don't describe me a list of bugs, I know 😂

import console
import ui

class cell_view(ui.View):
    def __init__(self,w,h,tableview,row):
        self.width = w
        self.height = h
        self.tv = tableview
        self.row = row
        if self.tv.swiped:
            prev = self.tv.swiped
            prev['bdel'].x = self.width
            prev['byou'].x = prev['bdel'].x + prev['bdel'].width

        self.tv.swiped = self
        #self.border_width = 1
        #self.border_color = 'red'
        bdel = ui.Button(name='bdel')
        bdel.frame = (w-0,0,w/8,self.height)
        bdel.background_color = 'red'
        bdel.tint_color = 'white'
        bdel.title = 'delete'
        bdel.action = self.but_action
        self.add_subview(bdel)
        byou = ui.Button(name='byou')
        byou.frame = (bdel.x+bdel.width,0,w/8,self.height)
        byou.background_color = 'green'
        byou.tint_color = 'white'
        byou.title = 'your edit'
        byou.action = self.but_action
        self.add_subview(byou)

    def touch_began(self, touch):
        #print('touch_began')
        self.x0,self.y0 = touch.location

    def touch_moved(self, touch):   
        x,y = touch.location
        if abs(y-self.y0) < 100:
            # swipe
            if x < self.x0:
                # swipe left
                self['bdel'].x = max(self['bdel'].x + (x-touch.prev_location.x), self.width*3/4)
                self['byou'].x = self['bdel'].x + self['bdel'].width
                if self.tv.swiped:
                    prev = self.tv.swiped
                    if prev != self:
                        prev['bdel'].x = self.width
                        prev['byou'].x = prev['bdel'].x + prev['bdel'].width
                        self.tv.swiped = self

    def touch_ended(self, touch):   
        pass

    def but_action(self,sender):
        tbl = self.tv
        row = self.row
        self['bdel'].x = self.width
        self['byou'].x = self['bdel'].x + self['bdel'].width
        self.tv.swiped = None
        if sender.title == 'delete':            
            b = console.alert('delete row',str(row), 'confirm', 'cancel', hide_cancel_button=True)
            if b == 1:
                del tbl.data_source.items[row]
                tbl.reload()
        elif sender.title == 'your edit':
            t = tbl.data_source.items[row]
            t = console.input_alert('enter text of row', str(row), t, 'ok', hide_cancel_button=True)
            tbl.data_source.items[row] = t

class MyTableView(ui.View):

    def __init__(self,w,h):
        self.width = w
        self.height = h

        tbl = ui.TableView()    
        tbl.swiped = None
        tbl.frame = (0,0,w,h)
        tbl.row_height = 50
        tbl.data_source = ui.ListDataSource(items=['a','b','c','d','e'])
        tbl.delegate = self
        tbl.data_source.tableview_cell_for_row = self.tableview_cell_for_row
        tbl.data_source.tableview_can_delete = self.tableview_can_delete
        tbl.background_color = (0,0,0,0)
        self.add_subview(tbl)       

    def tableview_cell_for_row(self, tableview, section, row):
        # Create and return a cell for the given section/row
        cell = ui.TableViewCell()
        cell.selectable = False
        cell.text_label.text = tableview.data_source.items[row]
        v = cell_view(self.width,tableview.row_height,tableview,row)
        cell.content_view.add_subview(v)
        return cell

    def tableview_can_delete(self, tableview, section, row):
        # Return True if the user should be able to delete the given row.
        return False    # to be sure that standard delete button is not displayed

def main():

    # Hide script
    w,h = ui.get_screen_size()
    mi = min(w,h)*0.9
    my_back = MyTableView(mi,mi)
    my_back.background_color='white'
    my_back.name = 'Test for @colint'   

    my_back.present('sheet',hide_title_bar=False)   
    my_back.wait_modal()

# Protect against import    
if __name__ == '__main__':
    main()
cvp

@colint ok, I'll stop with this last code, surely not perfect but, I think, sufficient to show you that you can do it like you asked. Even long swipe (where delete button width is more than half screen) is foreseen and simulates the tap of the delete button. Try the code and give me some feed-back.
I'm sure that you could ameliorate it in a better way than mine,

import console
import ui

class cell_view(ui.View):
    def __init__(self,w,h,tableview,row):
        self.width = w
        self.height = h
        self.tv = tableview
        self.row = row
        if self.tv.swiped:
            prev = self.tv.swiped
            prev['bdel'].x = self.width
            prev['byou'].x = prev['bdel'].x + prev['bdel'].width

        self.tv.swiped = self
        #self.border_width = 1
        #self.border_color = 'red'
        bdel = ui.Button(name='bdel')
        bdel.frame = (w-0,0,w/8,self.height)
        bdel.background_color = 'red'
        bdel.tint_color = 'white'
        bdel.title = 'delete'
        bdel.action = self.but_action
        self.add_subview(bdel)
        byou = ui.Button(name='byou')
        byou.frame = (bdel.x+bdel.width,0,w/8,self.height)
        byou.background_color = 'green'
        byou.tint_color = 'white'
        byou.title = 'your edit'
        byou.action = self.but_action
        self.add_subview(byou)

    def touch_began(self, touch):
        #print('touch_began')
        if self.tv.swiped:
            prev = self.tv.swiped
            if prev != self:
                prev['bdel'].x = self.width
                prev['byou'].x = prev['bdel'].x + prev['bdel'].width
                self.tv.swiped = None

        self.x0,self.y0 = touch.location

    def touch_moved(self, touch):   
        x,y = touch.location
        if abs(y-self.y0) < 100:
            # swipe left or right
            self['bdel'].x = min(max(self['bdel'].x + 2*(x-touch.prev_location.x), 0), self.width)
            self['bdel'].width = max(self.width/8,self.width*7/8-self['bdel'].x)
            #self['bdel'].x = min(max(self['bdel'].x + 2*(x-touch.prev_location.x), self.width*3/4), self.width)
            self['byou'].x = self['bdel'].x + self['bdel'].width
            self.tv.swiped = self

    def touch_ended(self, touch):   
        if self['bdel'].width > self.width/2:
            # automatic delete
            self.but_action('simul delete button')

    def but_action(self,sender):
        tbl = self.tv
        row = self.row
        self['bdel'].x = self.width
        self['bdel'].width = self.width/8
        self['byou'].x = self['bdel'].x + self['bdel'].width
        self.tv.swiped = None
        if sender.title == 'delete' or isinstance(sender, str):         
            b = console.alert('delete row',str(row), 'confirm', 'cancel', hide_cancel_button=True)
            if b == 1:
                del tbl.data_source.items[row]
                tbl.reload()
        elif sender.title == 'your edit':
            t = tbl.data_source.items[row]
            t = console.input_alert('enter text of row', str(row), t, 'ok', hide_cancel_button=True)
            tbl.data_source.items[row] = t

class MyTableView(ui.View):

    def __init__(self,w,h):
        self.width = w
        self.height = h

        tbl = ui.TableView()    
        tbl.swiped = None
        tbl.frame = (0,0,w,h)
        tbl.row_height = 50
        tbl.data_source = ui.ListDataSource(items=['a','b','c','d','e'])
        tbl.delegate = self
        tbl.data_source.tableview_cell_for_row = self.tableview_cell_for_row
        tbl.data_source.tableview_can_delete = self.tableview_can_delete
        tbl.background_color = (0,0,0,0)
        self.add_subview(tbl)       

    def tableview_cell_for_row(self, tableview, section, row):
        # Create and return a cell for the given section/row
        cell = ui.TableViewCell()
        cell.selectable = False
        cell.text_label.text = tableview.data_source.items[row]
        v = cell_view(self.width,tableview.row_height,tableview,row)
        cell.content_view.add_subview(v)
        return cell

    def tableview_can_delete(self, tableview, section, row):
        # Return True if the user should be able to delete the given row.
        return False    # to be sure that standard delete button is not displayed

def main():

    # Hide script
    w,h = ui.get_screen_size()
    mi = min(w,h)*0.9
    my_back = MyTableView(mi,mi)
    my_back.background_color='white'
    my_back.name = 'Test for @colint'   

    my_back.present('sheet',hide_title_bar=False)   
    my_back.wait_modal()

# Protect against import    
if __name__ == '__main__':
    main()
colint

Looking good! I’m not seeing the “your edit” button sliding out all the way but I’m sure I can figure that out. Thanks so much for your help!

cvp

@colint said

I’m not seeing the “your edit” button sliding out all the way

Swipe left is not sufficient, obviously when you begin location is too much at left.
Could be done automatically by code

colint

Sounds good! I’ll take a look into it, thanks again!