Python : How to add vertical scroll in RecycleView

Question

How to set text right align in only customerId column and other column
should be left align?

Solution

Snippet

def apply_selection(self, rv, index, is_selected):
    ''' Respond to the selection of items in the view. '''
    self.selected = is_selected
    self.text_size = self.size
    if index == rv.data[index]['range'][0]:
        self.halign = 'right'
    else:
        self.halign = 'left'

Output

Img8

Label – Text grow vertically and wraps at a certain width

A Label that can grow vertically but wraps the text at a certain width:

Label:
    size_hint_y: None
    text_size: self.width, None
    height: self.texture_size[1]

To scroll a GridLayout on it’s X-axis/horizontally and Y-axis/vertically, set size_hint property to (None, None).

ScrollView – ScrollEffect, scroll_type, bar_width, etc.

By default, the ScrollView allows scrolling along both the X and Y axes. You can explicitly disable scrolling on an axis by setting the do_scroll_x or do_scroll_y properties to False.

To scroll a GridLayout on it’s Y-axis/vertically, set the child’s
width to that of the ScrollView (size_hint_x=1), and set the
size_hint_y property to None:

When scrolling would exceed the bounds of the ScrollView, it uses a ScrollEffect to handle the overscroll.

scroll_type

Sets the type of scrolling to use for the content of the scrollview.
Available options are: [‘content’], [‘bars’], [‘bars’, ‘content’]

[‘bars’] Content is scrolled by dragging or swiping the scoll bars.

Grid Layout

cols_minimum

Dict of minimum width for each column. The dictionary keys are the
column numbers, e.g. 0, 1, 2…

cols_minimum is a DictProperty and defaults to {}.

Snippets

<RV>:
    row_controller: row_controller

    bar_width: 10
    bar_color: 1, 0, 0, 1   # red
    bar_inactive_color: 0, 0, 1, 1   # blue
    effect_cls: "ScrollEffect"
    scroll_type: ['bars']

    data: root.rv_data
    viewclass: 'SelectableButton'

    SelectableRecycleGridLayout:
        id: row_controller
        key_selection: 'selectable'
        cols: root.total_col_headings
        cols_minimum: root.cols_minimum
        default_size: None, dp(26)
        default_size_hint: 1, None
        size_hint: None, None
        height: self.minimum_height
        width: self.minimum_width
        orientation: 'vertical'
        multiselect: True
        touch_multiselect: True

The following example demonstrates:

  1. Scrolling of the table header on the X-axis.
  2. Support scrolling on the X-axis and Y-axis.
  3. Support scrolling on the Y-axis in the Popup window.
  4. Works for any columns in database table.
  5. Varying column width using GridLayout cols_minimum.
  6. Combined two RecycleView into one as there is no need to have a recycleview just for the ID.
  7. Selectable buttons.

SQLite Sample Database

chinook SQLite sample database

Example

main.py

from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.properties import BooleanProperty, ListProperty, ObjectProperty, NumericProperty, DictProperty

from kivy.uix.recycleview.views import RecycleDataViewBehavior
from kivy.uix.button import Button
from kivy.uix.recyclegridlayout import RecycleGridLayout
from kivy.uix.behaviors import FocusBehavior
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
from kivy.uix.popup import Popup
from kivy.core.window import Window
from kivy.clock import Clock
from kivy.uix.scrollview import ScrollView
from kivy.uix.label import Label
from kivy.uix.textinput import TextInput
from kivy.uix.recycleview import RecycleView
import sqlite3 as lite
import re

Window.size = (600, 325)


class PopupLabelCell(Label):
    pass


class EditStatePopup(Popup):

    def __init__(self, obj, **kwargs):
        super(EditStatePopup, self).__init__(**kwargs)
        self.populate_content(obj)

    def populate_content(self, obj):
        for x in range(len(obj.table_header.col_headings)):
            self.container.add_widget(PopupLabelCell(text=obj.table_header.col_headings[x]))
            textinput = TextInput(text=str(obj.row_data[x]))
            if x == 0:
                textinput.readonly = True
            self.container.add_widget(textinput)


class SelectableRecycleGridLayout(FocusBehavior, LayoutSelectionBehavior,
                                  RecycleGridLayout):
    ''' Adds selection and focus behaviour to the view. '''

    selected_row = NumericProperty(0)
    obj = ObjectProperty(None)

    def get_nodes(self):
        nodes = self.get_selectable_nodes()
        if self.nodes_order_reversed:
            nodes = nodes[::-1]
        if not nodes:
            return None, None

        selected = self.selected_nodes
        if not selected:    # nothing selected, select the first
            self.selected_row = 0
            self.select_row(nodes)
            return None, None

        if len(nodes) == 1:     # the only selectable node is selected already
            return None, None

        last = nodes.index(selected[-1])
        self.clear_selection()
        return last, nodes

    def select_next(self, obj):
        ''' Select next row '''
        self.obj = obj
        last, nodes = self.get_nodes()
        if not nodes:
            return

        if last == len(nodes) - 1:
            self.selected_row = nodes[0]
        else:
            self.selected_row = nodes[last + 1]

        self.selected_row += self.obj.total_col_headings
        self.select_row(nodes)

    def select_previous(self, obj):
        ''' Select previous row '''
        self.obj = obj
        last, nodes = self.get_nodes()
        if not nodes:
            return

        if not last:
            self.selected_row = nodes[-1]
        else:
            self.selected_row = nodes[last - 1]

        self.selected_row -= self.obj.total_col_headings
        self.select_row(nodes)

    def select_current(self, obj):
        ''' Select current row '''
        self.obj = obj
        last, nodes = self.get_nodes()
        if not nodes:
            return

        self.select_row(nodes)

    def select_row(self, nodes):
        col = self.obj.rv_data[self.selected_row]['range']
        for x in range(col[0], col[1] + 1):
            self.select_node(nodes[x])


class SelectableButton(RecycleDataViewBehavior, Button):
    ''' Add selection support to the Button '''
    index = None
    selected = BooleanProperty(False)
    selectable = BooleanProperty(True)

    def refresh_view_attrs(self, rv, index, data):
        ''' Catch and handle the view changes '''

        self.index = index
        return super(SelectableButton, self).refresh_view_attrs(rv, index, data)

    def on_touch_down(self, touch):
        ''' Add selection on touch down '''
        if super(SelectableButton, self).on_touch_down(touch):
            return True
        if self.collide_point(*touch.pos) and self.selectable:
            print("on_touch_down: self=", self)
            return self.parent.select_with_touch(self.index, touch)

    def apply_selection(self, rv, index, is_selected):
        ''' Respond to the selection of items in the view. '''
        self.selected = is_selected
        self.text_size = self.size
        if index == rv.data[index]['range'][0]:
            self.halign = 'right'
        else:
            self.halign = 'left'


class HeaderCell(Label):
    pass


class TableHeader(ScrollView):
    """Fixed table header that scrolls x with the data table"""
    header = ObjectProperty(None)
    col_headings = ListProperty([])
    cols_minimum = DictProperty({})

    def __init__(self, **kwargs):
        super(TableHeader, self).__init__(**kwargs)
        self.db = lite.connect('chinook.db')
        self.db_cursor = self.db.cursor()
        self.get_table_column_headings()

    def get_table_column_headings(self):
        self.db_cursor.execute("PRAGMA table_info(customers)")
        col_headings = self.db_cursor.fetchall()

        for col_heading in col_headings:
            data_type = col_heading[2]
            if data_type == "INTEGER":
                self.cols_minimum[col_heading[0]] = 100
            else:
                self.cols_minimum[col_heading[0]] = int(re.findall(r'\d+', data_type).pop(0)) * 5
            self.col_headings.append(col_heading[1])
            self.header.add_widget(HeaderCell(text=col_heading[1], width=self.cols_minimum[col_heading[0]]))


class RV(RecycleView):
    row_data = ()
    rv_data = ListProperty([])
    row_controller = ObjectProperty(None)
    total_col_headings = NumericProperty(0)
    cols_minimum = DictProperty({})
    table_header = ObjectProperty(None)

    def __init__(self, table_header, **kwargs):
        super(RV, self).__init__(**kwargs)
        self.table_header = table_header
        self.total_col_headings = len(table_header.col_headings)
        self.cols_minimum = table_header.cols_minimum
        self.database_connection()
        self.get_states()
        Clock.schedule_once(self.set_default_first_row, .0005)
        self._request_keyboard()

    def database_connection(self):
        self.db = lite.connect('chinook.db')
        self.db_cursor = self.db.cursor()

    def _request_keyboard(self):
        self._keyboard = Window.request_keyboard(
            self._keyboard_closed, self, 'text'
        )
        if self._keyboard.widget:
            # If it exists, this widget is a VKeyboard object which you can use
            # to change the keyboard layout.
            pass
        self._keyboard.bind(on_key_down=self._on_keyboard_down)

    def _keyboard_closed(self):
        self._keyboard.unbind(on_key_down=self._on_keyboard_down)
        self._keyboard = None

    def _on_keyboard_down(self, keyboard, keycode, text, modifiers):
        if keycode[1] == 'down':    # keycode[274, 'down'] pressed
            # Respond to keyboard down arrow pressed
            self.display_keystrokes(keyboard, keycode, text, modifiers)
            self.row_controller.select_next(self)

        elif keycode[1] == 'up':    # keycode[273, 'up] pressed
            # Respond to keyboard up arrow pressed
            self.display_keystrokes(keyboard, keycode, text, modifiers)
            self.row_controller.select_previous(self)

        elif len(modifiers) > 0 and modifiers[0] == 'ctrl' and text == 'e':     # ctrl + e pressed
            # Respond to keyboard ctrl + e pressed, and call Popup
            self.display_keystrokes(keyboard, keycode, text, modifiers)
            self.on_keyboard_select()

        # Keycode is composed of an integer + a string
        # If we hit escape, release the keyboard
        if keycode[1] == 'escape':
            keyboard.release()

        # Return True to accept the key. Otherwise, it will be used by
        # the system.
        return True

    def display_keystrokes(self, keyboard, keycode, text, modifiers):
        print("\nThe key", keycode, "have been pressed")
        print(" - text is %r" % text)
        print(" - modifiers are %r" % modifiers)

    def on_keyboard_select(self):
        ''' Respond to keyboard event to call Popup '''

        # setup row data for Popup
        self.setup_row_data(self.rv_data[self.row_controller.selected_row]['Index'])

        # call Popup
        self.popup_callback()

    def on_mouse_select(self, instance):
        ''' Respond to mouse event to call Popup '''

        if self.row_controller.selected_row != instance.index:
            # Mouse clicked on row is not equal to current selected row
            self.row_controller.selected_row = instance.index

            # Hightlight mouse clicked/selected row
            self.row_controller.select_current(self)

        # setup row data for Popup
        self.setup_row_data(self.rv_data[instance.index]['Index'])

        # call Popup
        self.popup_callback()

        # enable keyboard request
        self._request_keyboard()

    def setup_row_data(self, value):
        self.db_cursor.execute("SELECT * FROM customers WHERE CustomerId=?", value)
        self.row_data = self.db_cursor.fetchone()

    def popup_callback(self):
        ''' Instantiate and Open Popup '''
        popup = EditStatePopup(self)
        popup.open()

    def set_default_first_row(self, dt):
        ''' Set default first row as selected '''
        self.row_controller.select_next(self)

    def get_states(self):
        self.db_cursor.execute("SELECT * FROM customers ORDER BY CustomerId ASC")
        rows = self.db_cursor.fetchall()

        data = []
        low = 0
        high = self.total_col_headings - 1
        for row in rows:
            for i in range(len(row)):
                data.append([row[i], row[0], [low, high]])
            low += self.total_col_headings
            high += self.total_col_headings

        self.rv_data = [{'text': str(x[0]), 'Index': str(x[1]), 'range': x[2], 'selectable': True} for x in data]


class Table(BoxLayout):
    rv = ObjectProperty(None)

    def __init__(self, **kwargs):
        super(Table, self).__init__(**kwargs)
        self.orientation = "vertical"
        self.header = TableHeader()
        self.rv = RV(self.header)

        self.rv.fbind('scroll_x', self.scroll_with_header)

        self.add_widget(self.header)
        self.add_widget(self.rv)

    def scroll_with_header(self, obj, value):
        self.header.scroll_x = value


class MainMenu(BoxLayout):
    states_cities_or_areas = ObjectProperty(None)
    table = ObjectProperty(None)

    def display_states(self):
        self.remove_widgets()
        self.table = Table()
        self.states_cities_or_areas.add_widget(self.table)

    def remove_widgets(self):
        self.states_cities_or_areas.clear_widgets()


class TestApp(App):
    title = "test"

    def build(self):
        return MainMenu()


if __name__ == '__main__':
    TestApp().run()

test.kv

#:kivy 1.10.0

<PopupLabelCell>
    size_hint: (None, None)
    height: 30
    text_size: self.size
    halign: "left"
    valign: "middle"

<EditStatePopup>:
    container: container
    size_hint: None, None
    size: 400, 275
    title_size: 20
    # title_font: "Verdana"
    auto_dismiss: False

    BoxLayout:
        orientation: "vertical"
        ScrollView:
            bar_width: 10
            bar_color: 1, 0, 0, 1   # red
            bar_inactive_color: 0, 0, 1, 1   # blue
            effect_cls: "ScrollEffect"
            scroll_type: ['bars']
            size_hint: (1, None)

            GridLayout:
                id: container
                cols: 2
                row_default_height: 30
                cols_minimum: {0: 100, 1: 300}
                # spacing: 10, 10
                # padding: 20, 20
                size_hint: (None, None)
                height: self.minimum_height

        BoxLayout:
            Button:
                size_hint: 1, 0.2
                text: "Save Changes"
                on_release:
                    root.dismiss()
            Button:
                size_hint: 1, 0.2
                text: "Cancel Changes"
                on_release: root.dismiss()

<SelectableButton>:
    canvas.before:
        Color:
            rgba: (0, 0.517, 0.705, 1) if self.selected else (0, 0.517, 0.705, 1)
        Rectangle:
            pos: self.pos
            size: self.size
    background_color: [1, 0, 0, 1]  if self.selected else [1, 1, 1, 1]  # dark red else dark grey
    on_press: app.root.table.rv.on_mouse_select(self)

<HeaderCell>
    size_hint: (None, None)
    height: 25
    text_size: self.size
    halign: "left"
    valign: "middle"
    background_disabled_normal: ''
    disabled_color: (1, 1, 1, 1)

    canvas.before:
        Color:
            rgba: 1, 0.502, 0, 1
        Rectangle:
            pos: self.pos
            size: self.size

<TableHeader>:
    header: header
    bar_width: 0
    do_scroll: False
    size_hint: (1, None)
    height: 25
    effect_cls: "ScrollEffect"

    GridLayout:
        id: header
        rows: 1
        cols_minimum: root.cols_minimum
        size_hint: (None, None)
        width: self.minimum_width
        height: self.minimum_height

<RV>:
    row_controller: row_controller

    bar_width: 10
    bar_color: 1, 0, 0, 1   # red
    bar_inactive_color: 0, 0, 1, 1   # blue
    effect_cls: "ScrollEffect"
    scroll_type: ['bars']

    data: root.rv_data
    viewclass: 'SelectableButton'

    SelectableRecycleGridLayout:
        id: row_controller
        key_selection: 'selectable'
        cols: root.total_col_headings
        cols_minimum: root.cols_minimum
        default_size: None, dp(26)
        default_size_hint: 1, None
        size_hint: None, None
        height: self.minimum_height
        width: self.minimum_width
        orientation: 'vertical'
        multiselect: True
        touch_multiselect: True


<MenuButton@Button>:
    text_size: self.size
    valign: "middle"
    padding_x: 5
    size : (80,30)
    size_hint : (None, None)
    background_color: 90 , 90, 90, 90
    background_normal: ''
    color: 0, 0.517, 0.705, 1
    border: (0, 10, 0, 0)


<MainMenu>:
    states_cities_or_areas: states_cities_or_areas

    BoxLayout:
        orientation: 'vertical'

        BoxLayout:
            canvas.before:
                Rectangle:
                    pos: self.pos
                    size: self.size

            size_hint_y: 1

            MenuButton:
                id: btn
                text: 'Test'
                size : (60,30)
                on_release: root.display_states()


        BoxLayout:
            canvas.before:
                Rectangle:
                    pos: self.pos
                    size: self.size

                Color:
                    rgb: (1,1,1)

            Label:
                size_hint_x: 45

        BoxLayout:
            id: states_cities_or_areas
            size_hint_y: 10

        Label:
            size_hint_y: 1

Output

Img01
Img02
Img03
enter image description here

Leave a Comment