Skip to content

Instantly share code, notes, and snippets.

@homecoder
Last active January 4, 2020 07:16
Show Gist options
  • Save homecoder/5b356bdd0acbc45decd513e6b958a058 to your computer and use it in GitHub Desktop.
Save homecoder/5b356bdd0acbc45decd513e6b958a058 to your computer and use it in GitHub Desktop.
Pythonista TabView (Clone of iOS Tabbed Application)
# -*- coding: utf-8 -*-
"""
TabView - Clone of iOS Tabbed Application Bar
Copyright 2018 Michael Ruggiero
Released under MIT License - https://opensource.org/licenses/MIT
IMPORTANT: This was thrown together in tidbits of spare time while on my phone. It could definitely use a recfactor,
and as such, you may see some things which may not make sense, and/or aren't the greatest idea.
HOWEVER - It works! You can use this exactly as it is to create a primary view with a tab bar at the bottom.
Components:
TabButton - Refactored to clone a Pythonista Button, except with the text on the *bottom* of the image
TabControllerItem - Wrapper for the TabButton. This allows me to use the button in other projects separately, ish.
TabController - Really - No idea why I separated TabController and Item. But I did. Ugh.
TabView - The Tab View (Main Method you will use)
"""
import ui
import re
from six import text_type
from six import BytesIO
from PIL import Image, ImageDraw
class TabButton(ui.View):
"""
A ui.Button clone where the text appears below the image
Contains:
ui.Image
ui.Label
Set the frame or width/height, and optionally the space
between the image and title, the image will be sized
automatically.
Initially designed to be used with the TabView class (below),
but will create an Image Button and work exactly like a normal button.
At this juncture I am too lazy lets see
"""
def __init__(self,
render_mode=ui.CONTENT_SCALE_ASPECT_FIT,
**kwargs):
self.action_valid = False
self._image = ui.ImageView(y=5)
self._image.content_mode = render_mode
self._title = ui.Label(font=('<System>', 12))
self._action = None
self._enabled = True
self.view = None
self.render_mode = render_mode
self.image = kwargs.pop('image', None)
self.alpha = kwargs.pop('alpha', 1)
# Used for enable/disable
self._save_alpha = self.alpha
self.enabled = kwargs.pop('enabled', True)
self.action = kwargs.pop('action', None)
# self.background_image = kwargs.pop('background_image', None)
self.font = kwargs.pop('font', ('<system>', 12,))
self.title = kwargs.pop('title', 'Button')
self.width = kwargs.pop('width', 70)
self.height = kwargs.pop('height', 70)
# Set Padding, T, R, B, L ?
# self.padding = (5,5,5,5,)
self.title_pad = 5
self.image_width = kwargs.pop(
'image_width',
self.width
)
self.image_height = kwargs.pop(
'image_height',
self.height
)
for k, v in kwargs.items():
try:
setattr(self, k, v)
except AttributeError:
pass
@property
def action(self):
return self._action
@action.setter
def action(self, value):
"""
Using a setter/property to easily validate that action is callable
"""
if callable(value) or value is None:
self._action = value
else:
raise AttributeError('action must be callable or None')
@property
def enabled(self):
return self._enabled
@enabled.setter
def enabled(self, value):
self._enabled = value
if self._enabled:
self.alpha = self._save_alpha
self.touch_enabled = True
else:
self.alpha = 0.4
# noinspection PyAttributeOutsideInit
self.touch_enabled = False
@property
def image_width(self):
return self._image.width
@image_width.setter
def image_width(self, value):
self._image.width = value
@property
def image_height(self):
return self._image.height
@image_height.setter
def image_height(self, value):
self._image.height = value
@property
def title(self):
return self._title.text
@title.setter
def title(self, value):
if value:
self._title.text = value
self._add_title()
@property
def image(self):
return self._image.image
@image.setter
def image(self, value):
# assert isinstance(value, ui.Image)
if value:
self._image.image = value.with_rendering_mode(self.render_mode)
self._add_image()
# noinspection PyPep8
def _add_image(self):
# remove any existing images
try:
for s in self.subviews:
if isinstance(s, ui.Image):
self.remove_subview(s)
except:
pass
# We have an image
if isinstance(self._image, ui.ImageView):
self.add_subview(self._image)
self.set_needs_display()
return
def _add_title(self):
try:
for s in self.subviews:
if isinstance(s, ui.Label):
self.remove_subview(s)
except:
pass
# We have an image
if isinstance(self._title, ui.Label):
if self._title.text is not None:
self.add_subview(self._title)
pass
self.set_needs_display()
return
def draw(self):
# set the button height - hack for now
try:
self.height = (self.superview.height - 5)
except:
pass
# set the image height without text
if self.image:
self._image.width = self.width
self._image.height = self.height
if self.title:
w, h = ui.measure_string(self._title.text,
font=self.font,
alignment=ui.ALIGN_CENTER, )
self._title.frame = (0, (self.height - h), self.width, h)
try:
self._image.height -= (h + self.title_pad)
except:
print('failed to set image height')
pass
try:
# Setup the Text to center in the view
self._title.width = self.width
self._title.alignment = ui.ALIGN_CENTER
except:
pass
def touch_began(self, touch):
_ = touch # Shadap IDE
self.alpha = 0.3
pass
def touch_ended(self, touch):
self.alpha = 1
lw, lh = touch.location
if 0 <= lw <= self.width and 0 <= lh <= self.height:
self.action(self.view)
class TabControllerItem(object):
_image = None
_title = None
_view = None
_name = None
def __init__(self, **kwargs):
"""
Class Initializer
"""
# Allow setting of properties via keyword arg
self.image = kwargs.pop('image', None)
self.title = kwargs.pop('title', None)
self.view = kwargs.pop('view', None)
self.name = kwargs.pop('name', None)
self.action = kwargs.pop('action', None)
@property
def image(self):
return self._image
@image.setter
def image(self, image):
if not any((isinstance(image, text_type),
isinstance(image, ui.Image),
image is None)):
raise AttributeError('image must be one of: str, ui.Image, None')
if isinstance(image, text_type):
image = ui.Image.named(image)
self._image = image
@property
def title(self):
return self._title
@title.setter
def title(self, title):
if title is not None and not isinstance(title, text_type):
raise AttributeError('title must be one of: string or None')
self._title = title
@property
def view(self):
return self._view
@view.setter
def view(self, view):
if not isinstance(view, ui.View) and view is not None:
raise AttributeError('view must be a Pythonista View')
self._view = view
@property
def name(self):
return self._name
@name.setter
def name(self, name):
if name is not None:
self._name = name
elif self.title is not None:
# Setup stuff here
self._name = re.sub(r'([^\s\w_])+', '', self.title)
self.name = self._name.replace(' ', '_')
elif isinstance(self.image, ui.Image):
self.name = id(self.image)
else:
self.name = id(self)
@property
def button(self):
"""
Provide a button
"""
button = TabButton(
title=self.title,
image=self.image,
view=self.view,
name=self.name,
action=self.action,
)
return button
class TabController(object):
"""
Tab Controller
This is a helper class designed to further simplify the tab buttons.
"""
def __init__(self, action, *args, **kwargs):
self.tabs = []
self.action = action
if args:
for tab in args:
if isinstance(tab, dict):
self.add(**tab)
if kwargs:
if 'tabs' in kwargs:
tabs = kwargs['tabs']
if isinstance(tabs, list):
for tab in tabs:
self.add(**tab)
pass
def add(self, image, title, view, name=None):
"""
Add a(n) Button/Option to the Tab Controller.
:param (str|ui.Image|None) image: The Image you wish to use
:param (str|None) title: The title / text below the image - None for blank
:param ui.View view: Pythonista View you wish to associate with the button
:param str name: Name of the tab, See Below:
The name is used as an identifier, if it is not set, then it will automatically be set as:
1. The ui.View name, if not None
2. The title, lower(), with _ instead of spaces, punctuation removed, i.e.
Title: "Fav Thing's", Name: fav_things; if not None.
3. The image objects "id" - if not None; see Python's id()
4. The TabControllerItem's id; see Python's id()
:return self: Returns self to enable method chaining
"""
item = TabControllerItem(
image=image,
title=title,
view=view,
name=name,
action=self.action)
# Note: Originally I was going to set an order, but there really is no need.
# TODO: Refactor
self.tabs.append(item)
return self
def remove(self, name=None, view=None):
"""
This is not tested.
"""
assert any((name, view,))
for tab in self.tabs:
if (name and tab.name == name) or (view and tab.view == view):
self.tabs.pop(self.tabs.index(tab))
class TabView(ui.View):
"""
A TabView Controller - This is a clone of the "iOS Tabbed Application" Layout
This sets up a set of Tab Icons which can be used to switch views.
"""
def __init__(self, tabs=None, height=75, **kwargs):
w, h = ui.get_window_size()
self.flex = 'WH'
self.frame = (0, 0, w, h)
self.tab_height = height
self.name = 'Select Drink'
self.content_view = ui.View(
name='content',
frame=(0, 0, w, (h - height)),
flex='WB'
)
self.tab_view = ui.View(
background_color='#eee',
frame=(0, (h - height), w, height),
border_color='#ccc',
border_width=0.5,
flex='WT'
)
controller_options = dict(
action=self.load_view,
)
if isinstance(tabs, list):
controller_options['tabs'] = tabs
self.controller = TabController(**controller_options)
self.background_color = kwargs.pop('background_color', '#ffffff')
# Add buttons
button_width = int(self.width / len(self.controller.tabs))
count = 0
for tab in self.controller.tabs:
button = tab.button
button.x = (count * button_width)
button.width = button_width
button.y += 3
self.tab_view.add_subview(button)
if 0 < count < len(self.controller.tabs):
sep = ui.ImageView(
image=self.separator(),
frame=((count * button_width), 0, 3, 70)
)
self.tab_view.add_subview(sep)
count += 1
# add components as subviews
if len(self.controller.tabs) > 0:
# Use first tab as default view for now
default_view = self.controller.tabs[0].view
self.content_view.add_subview(
default_view
)
self.add_subview(self.content_view)
self.add_subview(self.tab_view)
def draw(self):
w, h = self.width, self.height
height = self.tab_height
self.content_view.frame = (0, 0, w, (h - height))
for view in self.content_view.subviews:
view.frame = self.content_view.frame
def load_view(self, view):
"""
Remove other subviews and plug in requested one
"""
for v in self.content_view.subviews:
self.content_view.remove_subview(v)
view.frame = self.content_view.frame
self.name = view.name
self.content_view.add_subview(view)
def separator(self):
img = Image.new('RGB', (4, 80), self.hex_to_rgb('#eeeeee'))
draw = ImageDraw.Draw(img)
draw.line((1.5, 10, 1.5, 75), self.hex_to_rgb('#dedede'), 1)
return self.pil_to_ui_image(img)
@staticmethod
def hex_to_rgb(value):
value = value.lstrip('#')
lv = len(value)
return tuple(int(value[i:i + lv // 3], 16) for i in range(0, lv, lv // 3))
@staticmethod
def rgb_to_hex(rgb):
return '#%02x%02x%02x' % rgb
@staticmethod
def pil_to_ui_image(ip):
with BytesIO() as bIO:
ip.save(bIO, 'PNG')
img = ui.Image.from_data(bIO.getvalue())
img = img.with_rendering_mode(ui.RENDERING_MODE_ORIGINAL)
return img
if __name__ == '__main__':
"""
Main View App
"""
def viewlabel(label_text):
w, h = ui.get_window_size()
lh = (h - 60) / 2 - 5
lbl = ui.Label(
x=0,
y=lh,
text=label_text,
name=label_text,
alignment=ui.ALIGN_CENTER,
)
return lbl
#beer = viewlabel('Beer')
#wine = viewlabel('Wine')
#beaker = viewlabel('Beaker')
beer = ui.load_view('example1')
wine = ui.load_view('example2')
beaker = ui.load_view('example3')
tabs = [{
'name': 'beer',
'title': 'Beer',
'image': ui.Image.named('iob:beer_32'),
'view': beer,
}, {
'name': 'wine',
'title': 'Wine',
'image': ui.Image.named('iob:wineglass_32'),
'view': wine,
}, {
'name': 'beaker',
'title': 'Beaker',
'image': ui.Image.named('iob:beaker_32'),
'view': beaker,
}]
main = TabView(tabs=tabs, height=60)
main.present()
[
{
"nodes" : [
{
"nodes" : [
],
"frame" : "{{20, 18}, {285, 265}}",
"class" : "ImageView",
"attributes" : {
"flex" : "WH",
"frame" : "{{110, 190}, {100, 100}}",
"image_name" : "iob:beer_256",
"class" : "ImageView",
"name" : "image",
"uuid" : "C9B2B27B-A55C-4020-BB0C-AEEEB205F034"
},
"selected" : false
},
{
"nodes" : [
],
"frame" : "{{20, 291}, {285, 66}}",
"class" : "Label",
"attributes" : {
"name" : "label-words",
"flex" : "WT",
"frame" : "{{85, 224}, {150, 32}}",
"uuid" : "A48133AD-8BF6-4157-A1D1-44A21CC3CD24",
"class" : "Label",
"alignment" : "center",
"text" : "Wouldn't it be amazing if an app could give you a frosty beer?",
"font_size" : 18,
"font_name" : "<System>"
},
"selected" : false
},
{
"nodes" : [
],
"frame" : "{{231, 385}, {74, 74}}",
"class" : "ImageView",
"attributes" : {
"flex" : "LT",
"frame" : "{{110, 190}, {100, 100}}",
"image_name" : "iob:battery_charging_32",
"class" : "ImageView",
"name" : "imageview2",
"uuid" : "F5EA03E1-36B5-4B1D-AD34-FD5939187250"
},
"selected" : false
},
{
"nodes" : [
],
"frame" : "{{124, 385}, {74, 74}}",
"class" : "ImageView",
"attributes" : {
"flex" : "LRT",
"frame" : "{{110, 190}, {100, 100}}",
"image_name" : "iob:arrow_right_a_256",
"class" : "ImageView",
"name" : "imageview2",
"uuid" : "03D02095-42DE-43AA-95EA-EBC3B11F6F8B"
},
"selected" : false
},
{
"nodes" : [
],
"frame" : "{{16, 385}, {74, 74}}",
"class" : "ImageView",
"attributes" : {
"flex" : "RT",
"frame" : "{{110, 190}, {100, 100}}",
"image_name" : "iob:beer_32",
"class" : "ImageView",
"name" : "imageview3",
"uuid" : "486807C7-3E30-4444-BE9E-4898F96A66AC"
},
"selected" : false
}
],
"frame" : "{{0, 0}, {320, 480}}",
"class" : "View",
"attributes" : {
"name" : "Beer",
"enabled" : true,
"background_color" : "RGBA(1.000000,1.000000,1.000000,1.000000)",
"tint_color" : "RGBA(0.000000,0.478000,1.000000,1.000000)",
"border_color" : "RGBA(0.000000,0.000000,0.000000,1.000000)",
"flex" : ""
},
"selected" : false
}
]
[
{
"nodes" : [
{
"nodes" : [
],
"frame" : "{{20, 18}, {285, 265}}",
"class" : "ImageView",
"attributes" : {
"flex" : "WH",
"frame" : "{{110, 190}, {100, 100}}",
"image_name" : "iob:wineglass_256",
"class" : "ImageView",
"name" : "image",
"uuid" : "C9B2B27B-A55C-4020-BB0C-AEEEB205F034"
},
"selected" : false
},
{
"nodes" : [
],
"frame" : "{{20, 291}, {285, 66}}",
"class" : "Label",
"attributes" : {
"flex" : "WT",
"font_size" : 18,
"frame" : "{{85, 224}, {150, 32}}",
"uuid" : "A48133AD-8BF6-4157-A1D1-44A21CC3CD24",
"class" : "Label",
"alignment" : "center",
"text" : "Wouldn't it be amazing if an app could give you a devine vintage?",
"name" : "label-words",
"font_name" : "<System>"
},
"selected" : false
},
{
"nodes" : [
],
"frame" : "{{231, 385}, {74, 74}}",
"class" : "ImageView",
"attributes" : {
"flex" : "LT",
"frame" : "{{110, 190}, {100, 100}}",
"image_name" : "iob:battery_charging_32",
"class" : "ImageView",
"name" : "imageview2",
"uuid" : "F5EA03E1-36B5-4B1D-AD34-FD5939187250"
},
"selected" : false
},
{
"nodes" : [
],
"frame" : "{{124, 385}, {74, 74}}",
"class" : "ImageView",
"attributes" : {
"flex" : "LRT",
"frame" : "{{110, 190}, {100, 100}}",
"image_name" : "iob:arrow_right_a_256",
"class" : "ImageView",
"name" : "imageview2",
"uuid" : "03D02095-42DE-43AA-95EA-EBC3B11F6F8B"
},
"selected" : false
},
{
"nodes" : [
],
"frame" : "{{16, 385}, {74, 74}}",
"class" : "ImageView",
"attributes" : {
"flex" : "RT",
"frame" : "{{110, 190}, {100, 100}}",
"image_name" : "iob:wineglass_32",
"class" : "ImageView",
"name" : "imageview3",
"uuid" : "486807C7-3E30-4444-BE9E-4898F96A66AC"
},
"selected" : false
}
],
"frame" : "{{0, 0}, {320, 480}}",
"class" : "View",
"attributes" : {
"border_color" : "RGBA(0.000000,0.000000,0.000000,1.000000)",
"enabled" : true,
"background_color" : "RGBA(1.000000,1.000000,1.000000,1.000000)",
"name" : "Wine",
"tint_color" : "RGBA(0.000000,0.478000,1.000000,1.000000)",
"flex" : ""
},
"selected" : false
}
]
[
{
"nodes" : [
{
"nodes" : [
],
"frame" : "{{20, 18}, {285, 265}}",
"class" : "ImageView",
"attributes" : {
"flex" : "WH",
"frame" : "{{110, 190}, {100, 100}}",
"image_name" : "iob:beaker_256",
"class" : "ImageView",
"name" : "image",
"uuid" : "C9B2B27B-A55C-4020-BB0C-AEEEB205F034"
},
"selected" : false
},
{
"nodes" : [
],
"frame" : "{{20, 291}, {285, 66}}",
"class" : "Label",
"attributes" : {
"flex" : "WT",
"font_size" : 18,
"frame" : "{{85, 224}, {150, 32}}",
"uuid" : "A48133AD-8BF6-4157-A1D1-44A21CC3CD24",
"class" : "Label",
"alignment" : "center",
"text" : "Wouldn't it be amazing if an app could give you an inebreating concocution?",
"name" : "label-words",
"font_name" : "<System>"
},
"selected" : false
},
{
"nodes" : [
],
"frame" : "{{231, 385}, {74, 74}}",
"class" : "ImageView",
"attributes" : {
"flex" : "LT",
"frame" : "{{110, 190}, {100, 100}}",
"image_name" : "iob:battery_charging_32",
"class" : "ImageView",
"name" : "imageview2",
"uuid" : "F5EA03E1-36B5-4B1D-AD34-FD5939187250"
},
"selected" : false
},
{
"nodes" : [
],
"frame" : "{{124, 385}, {74, 74}}",
"class" : "ImageView",
"attributes" : {
"flex" : "LRT",
"frame" : "{{110, 190}, {100, 100}}",
"image_name" : "iob:arrow_right_a_256",
"class" : "ImageView",
"name" : "imageview2",
"uuid" : "03D02095-42DE-43AA-95EA-EBC3B11F6F8B"
},
"selected" : false
},
{
"nodes" : [
],
"frame" : "{{16, 385}, {74, 74}}",
"class" : "ImageView",
"attributes" : {
"flex" : "RT",
"frame" : "{{110, 190}, {100, 100}}",
"image_name" : "iob:beaker_256",
"class" : "ImageView",
"name" : "imageview3",
"uuid" : "486807C7-3E30-4444-BE9E-4898F96A66AC"
},
"selected" : false
}
],
"frame" : "{{0, 0}, {320, 480}}",
"class" : "View",
"attributes" : {
"border_color" : "RGBA(0.000000,0.000000,0.000000,1.000000)",
"enabled" : true,
"background_color" : "RGBA(1.000000,1.000000,1.000000,1.000000)",
"name" : "Absinthe",
"tint_color" : "RGBA(0.000000,0.478000,1.000000,1.000000)",
"flex" : ""
},
"selected" : false
}
]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment