Last active
January 4, 2020 07:16
-
-
Save homecoder/5b356bdd0acbc45decd513e6b958a058 to your computer and use it in GitHub Desktop.
Pythonista TabView (Clone of iOS Tabbed Application)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# -*- 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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[ | |
{ | |
"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 | |
} | |
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[ | |
{ | |
"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 | |
} | |
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[ | |
{ | |
"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