Welcome!
This is the community forum for my apps Pythonista and Editorial.
For individual support questions, you can also send an email. If you have a very short question or just want to say hello — I'm @olemoritz on Twitter.
UIPageControl (Paging scrollview with page indicators)
-
Hello!
For a project i’m currently working on I needed a UIPageControl view, I thought I would share this here in case anyone else needed it. I apologize for my terrible code style. I am very open to criticism on anything I should change.
import ui from objc_util import ObjCClass, CGRect, create_objc_class, ObjCInstance, UIColor UIPageControl = ObjCClass('UIPageControl') def changePage(_self, _cmd): self = ObjCInstance(_self) self.page_control.set_page(self.page_control.pageControl.currentPage()) ChangePageClass = create_objc_class("ChangePageClass", methods=[changePage]) class PageControl(ui.View): def __init__(self, **kwargs): self.scrollView = ui.ScrollView( delegate=self, paging_enabled=True, shows_horizontal_scroll_indicator=False, bounces=False, frame=self.bounds, flex='WH', ) self.pageControl = UIPageControl.alloc().init().autorelease() self._target = ChangePageClass.new().autorelease() self._target.page_control = self self.pageControl.addTarget_action_forControlEvents_(self._target, 'changePage', 1 << 12) #1<<12 = 4096 self.pageControl.numberOfPages = len(self.scrollView.subviews) self.pageControl.currentPage = 0 self.pageControl.hidesForSinglePage = True self._prev_page = 0 super().add_subview(self.scrollView) ObjCInstance(self).addSubview_(self.pageControl) super().__init__(**kwargs) def present(self, *args, **kwargs): if 'hide_title_bar' in kwargs and kwargs['hide_title_bar']: #Temp work around for possible bug. background = ui.View(background_color=self.background_color) background.present(*args, **kwargs) self.frame = background.bounds background.add_subview(self) else: super().present(*args, **kwargs) def layout(self): self.scrollView.content_size = (self.scrollView.width * len(self.scrollView.subviews), 0) safe_bottom = self.bounds.max_y - self.objc_instance.safeAreaInsets().bottom size = self.pageControl.sizeForNumberOfPages_(self.pageControl.numberOfPages()) self.pageControl.frame = CGRect( (self.bounds.center().x - self.bounds.width / 2, safe_bottom - size.height), (self.bounds.width, size.height)) for i, v in enumerate(self.scrollView.subviews): v.x = i * self.bounds.width self.set_page(self.pageControl.currentPage()) def scrollview_did_scroll(self, scrollView): pageNumber = round(self.scrollView.content_offset[0] / (self.scrollView.content_size.width/len(self.scrollView.subviews)+1)) self.pageControl.currentPage = pageNumber self._trigger_delegate() def add_subview(self, page): self.pageControl.numberOfPages = len(self.scrollView.subviews) + 1 page.frame = self.scrollView.bounds page.flex = 'WH' self.scrollView.add_subview(page) self.layout() def _objc_color(self, color): return UIColor.colorWithRed_green_blue_alpha_(*ui.parse_color(color)) def _py_color(self, objc_color): return tuple([c.floatValue() for c in objc_color.arrayFromRGBAComponents()]) if objc_color else None def _trigger_delegate(self): try: callback = self.delegate.page_changed except AttributeError: return if self.pageControl.currentPage() is not self._prev_page: callback(self, self.pageControl.currentPage()) self._prev_page = self.pageControl.currentPage() def set_page(self, page_number): if page_number < self.pageControl.numberOfPages() and page_number > -1: x = page_number * self.scrollView.width self.scrollView.content_offset = (x, 0) else: raise ValueError("Invalid Page Number. page_number is zero indexing.") @property def page_count(self): return self.pageControl.numberOfPages() @property def current_page(self): return self.pageControl.currentPage() @property def hide_on_single_page(self): return self.pageControl.hidesForSinglePage() @hide_on_single_page.setter def hide_on_single_page(self, val): self.pageControl.hidesForSinglePage = val @property def indicator_tint_color(self): """Returns un-selected tint color, returns None as default due to .pageIndicatorTintColor() returning that""" return self._py_color(self.pageControl.pageIndicatorTintColor()) @indicator_tint_color.setter def indicator_tint_color(self, val): self.pageControl.pageIndicatorTintColor = self._objc_color(val) @property def indicator_current_color(self): """Returns selected tint color, returns None as default due to .currentPageIndicatorTintColor() returning that""" return self._py_color(self.pageControl.currentPageIndicatorTintColor()) @indicator_current_color.setter def indicator_current_color(self, val): self.pageControl.currentPageIndicatorTintColor = self._objc_color(val) if __name__ == '__main__': class SampleDelegate(): def __init__(self): pass def page_changed(self, sender, page_number): """Gets called every time the page changes.""" print(f'Sender: {sender}, Delegate: {page_number}') pages = PageControl() sv = ui.View() sv.background_color = 'red' sv1B = ui.Button() sv1B.title = "Go To Page 3 (index 2)" sv1B.frame = (100,100,200,100) def btn_action(sender): pages.set_page(2) sv1B.action = btn_action sv.add_subview(sv1B) sv2 = ui.View() sv2.background_color = 'blue' sv3 = ui.View() sv3.background_color = 'yellow' pages.indicator_tint_color = 'brown' pages.indicator_current_color = 'black' pages.add_subview(sv) pages.add_subview(sv2) pages.add_subview(sv3) pages.delegate = SampleDelegate() print(pages.indicator_tint_color) print(pages.indicator_current_color) print(pages.page_count) print(pages.current_page) print(pages.hide_on_single_page) pages.present('fullscreen')
Gist: https://gist.github.com/samerbam/8062c6075283a2c190812ddc925c015a
Changelog:
v0.2:
m.scrollView.add_subview(view)
->m.add_subview(view)
m.tintColor
to set un-selected dot colourm.currentTintColor
to set selected dot colour- No longer need to manually set x position on each page
— x and y pos acts as offset from edge of screen (like normal) - Moved some objc class creations out of __init__ (still working on this)
- Mirrored to gist
v0.3:
- Finished moving objc class creations out of
__init__
m.TintColor
->m.tint_color
m.currentTintColor
->m.current_tint_color
m.current_page
returns the current page your onm.page_count
returns total amount of pages- Switched from
ui.get_screen_size()
to usingself.bounds
— Should allow for compatibility with more advanced UI’s
v0.4
- Fixed bug where tapping on either side of dots to change page would only work on first launch.
m.tint_color
->m.indicator_tint_color
m.current_tint_color
->m.indicator_current_color
- Added delegate callback
page_changed
set delegate to class that includespage_changed(self, page_number)
- Added
set_page
method to change the page programmatically - Added
hide_on_single_page
attribute (defaults to true) hides dots with only one page.
v0.5
- Changed example slightly to include a weirdly placed button.
- Added explicit call to
self.layout()
inadd_subview
- Removed call to
_trigger_delegate
fromset_page
, asset_page
triggersscrollview_did_scroll
which calls_trigger_delegate
v0.6
- Switched from
hasattr
totry: except AttributeError:
- Implemented a work around for a bug involving the view jumping down ~20 pixels. (Really dislike this, however it seems like the best I could do at the moment)
- Removed unnecessary attribute
pNum
- Switched from setting
width
andheight
of each subview manually to usingflex
- Renamed
PageControl
instance in example topages
fromm
-
@Samer, nice! I had not even realized there was a specific control for this. (And nothing wrong with your coding style.)
Couple of further development suggestions, if you want to increase the overall reusability of the implementation.
- Move ObjC class creations outside init, to avoid creating them for every instance.
- Support adding views without explicitly setting a size or number of pages. Would suggest overriding
add_subview
for ultimate convenience. - Tweak the previous to support device rotation and arbitrary sized parent ScrollView (set
flex
on PageControl, overridelayout
).
-
@Samer As you import UIColor, I suppose you know that you can define indicators colors 😀
self.pageControl.setPageIndicatorTintColor_(ObjCClass('UIColor').color( red=0, green=0, blue=0, alpha=1.0)) self.pageControl.setCurrentPageIndicatorTintColor_(ObjCClass('UIColor').color( red=1, green=1, blue=1, alpha=1.0))
-
@cvp, yes, add the colors as constructor parameters for PageControl, but by default follow the theme colors.
When setting ObjC colors, I like to support all variations of defining the colors by using
ui.parse_color
:UIColor.colorWithRed_green_blue_alpha_(*ui.parse_color('teal'))
-
@mikael Understood, but you have to know the color name in English, don't you?
I prefer set as an r,g,b, but all is allowed, no best way -
@cvp, my point was that if you want to create a reusable component with color parameters, the construct works just as well with any way the developer wants to define the color:
import objc_util import ui UIColor = objc_util.ObjCClass('UIColor') def objc_color(color): return UIColor.colorWithRed_green_blue_alpha_(*ui.parse_color(color)) print(objc_color('red')) print(objc_color((1,0,0))) print(objc_color((1,0,0,1))) print(objc_color('#F00'))
-
@mikael ok, finally, I did not understand correctly 😢, sorry
-
@mikael Thanks for the feedback! I have a few questions about how I would go about overriding
add_subview
. How would I add the subview to the view.This snippet:
def add_subview(self, v): self.scrollView.add_subview(v)
Gives me the error:
ValueError: Cannot add self as subview
. -
@Samer, the method looks good to me. How are you calling it?
In my mind, the point of this method would be that it takes care of placing the new view to the right of the previous view, as well as updating the content_size and page counts, i.e. these move away from the
__init__
and the user of the component does not need to worry about the placements of the views. -
After a few week break, I have just updated this post with a new version that implements some of the great feedback I got.
-
@Samer, thanks for bearing with my comments. I enjoy this kind of almost pair programming, but just let me know if you get fed up with my nitpicking.
Some further suggestions:
-
UIColor
is already defined byobjc_util
. (Not documented, but code completion shows it.) -
You can move the ObjC class creation out of init if, instead of using closure, you set
target.page_control = self
in init, and access the values in thechangePage
method with:self = ObjCInstance(_self) self.page_control self.page_control.scrollView
(This works with attributes but not as well with methods, due to the way ObjCInstance proxy is implemented.)
-
The color properties are convenient, but return None if the defaults are not changed. I would prefer just to use a "reverse" color function, see below. It would have the additional benefit of removing the extra internal variables.
def _py_color(self, objc_color): return tuple([c.floatValue() for c in objc_color.arrayFromRGBAComponents()])
-
You can just name the method
add_subview
. ScrollView can be added withsuper().add_subview()
. -
Sizing of the pages would be best based on
self.bounds.width
andself.bounds.height
, instead of the screen size, which is a very specific case and prevents anyone using the component as a part of a more complicated UI. (And the internalbounds
instead of the externalframe
, just to tolerate the remote possibility of someone adding atransform
to your component.) -
Strongly recommend moving all the placement code to a
layout
method, where you just iterate through the scrollViewsubviews()
, size them according toself.bounds
, place them side-by-side and updatecontent_size
.layout
gets called on rotation if the component is set toflex
, andadd_subview
can call it directly. This makes the component completely responsive, and the developer can justadd_subview
s without worrying about placement. -
(One more read-only convenience property comes to mind,
page_count
, not exactly sure what for, though.) -
Last but not least, I would change the naming of all externally visible methods and properties to be Pythonic, i.e.
tint_color
instead oftintColor
. While only syntactic, it is in line with all Pythonista modules, and tells the developer that they do not have to understand any of the ObjC parts of using your component. And while we are at it, changechangePageClass
to start with an upper case letter to signal that it is a class.
-
-
- Hope i didnt Scratch it up too bad.. 😬😅😬 *
from ui import (ScrollView, View, get_screen_size, Label, ALIGN_CENTER) from objc_util import (ObjCClass, CGRect, create_objc_class, ObjCInstance) from random import random as rnd class PageControl(View): def __init__(self, tintColor=None, currentTintColor=None): self.w, self.h = get_screen_size() self.pages=dict({}) self.scrollView = ScrollView(frame=(0,0,self.w, self.h), delegate=self, paging_enabled=True, bounces=False, shows_horizontal_scroll_indicator=False ) self.add_subview(self.scrollView) self.pageControl = ObjCClass('UIPageControl').alloc().initWithFrame_(CGRect((0,self.h*0.8),(100,100))).autorelease() self.pageControl.addTarget_action_forControlEvents_(create_objc_class("target", methods=[self.changePage]).new().autorelease(), 'changePage', 1<<12) #1<<12 = 4096 self.pageControl.numberOfPages = len(self.scrollView.subviews) self_objc = ObjCInstance(self).addSubview_(self.pageControl) if tintColor: self.pageControl.pageIndicatorTintColor = self._objc_color(tintColor) if currentTintColor: self.pageControl.currentPageIndicatorTintColor = self._objc_color(currentTintColor) def scrollview_did_scroll(self, scrollView): self.pageControl.currentPage = round( scrollView.content_offset[0] / scrollView.width) def _add_subview(self, v): v.x = v.x + len(self.scrollView.subviews)*self.scrollView.width self.scrollView.add_subview(v) self.pageControl.numberOfPages = len(self.scrollView.subviews) self.scrollView.content_size = (self.scrollView.width * (len(self.scrollView.subviews)), 0) def changePage(self, cmd): self.scrollView.content_offset = (self.pageControl.currentPage() * self.scrollView.width, 0) def _objc_color(self, color): return ObjCClass('UIColor').colorWithRed_green_blue_alpha_(*parse_color(color)) @property def tintColor(self): return self._tintColor @tintColor.setter def tintColor(self, val): self.pageControl.pageIndicatorTintColor = self._objc_color(val) @property def currentTintColor(self): return self._currentTintColor @currentTintColor.setter def currentTintColor(self, val): self.pageControl.currentPageIndicatorTintColor = self._objc_color(val) def PageTitle(self, tag): l= Label(text=tag, font=('<System-Bold>', 16)) l.text_color=(0.25, 0.14, 0.01, 1.0) l.width = 250 l.alignment=ALIGN_CENTER l.x, l.y= self.w/2-l.width/2, l.height+20 return l def addPage(self, tag="", frame=None, bgc=None, **kwargs): if not bgc: bgc = r, g, b, a=rnd()*0.5+0.35, rnd()*0.5+0.35, rnd()*0.5+0.35, 1.0 if not frame: frame =(0, 0, self.w, self.h) v= View(name=tag, frame=frame, background_color=bgc, **kwargs) if tag: self.pages[tag]=v v.add_subview(self.PageTitle(tag)) self._add_subview(v) return v if __name__ == '__main__': m = PageControl() m.addPage('Table of Content', bgc="white") m.addPage('Index') for chap in range(1, 11): m.addPage(f"chapter {chap}") m.present('fullscreen')
-
@mikael I really enjoy this type of programming as well, It gives me an external view and makes me aware of certain use cases I was not aware of.
- Thanks for letting me know of super().add_subview() wish I had known about that earlier :|
- Could you explain what you mean by a
layout
method? I’m relatively new to pythonista (not so much python itself) and the UI module.
-
Layout
pythonista Docs
def layout(self): # This will be called when a view is resized. You should typically set the # frames of the view's subviews here, if your layout requirements cannot # be fulfilled with the standard auto-resizing (flex) attribute. pass
often used to signal when screen orientation has changed.
also in
scene
Moduledef did_change_size(self): pass
-
@Samer Heres a scene example being used to adust the gui when orientation changes. i apologize about the clutter i just grabbed from a current project so the codevwont run as is. just a visual use example. hope it helps..
class UICanvas(GameObject): def __init__(ns, size, *args, **kwargs): GameObject.__init__(ns) ns.size=size ns.OverlaySize=Point(0.0, 0.0) ns.joySize=Point(128, 128) def awake(ns): pass def start(ns): w, h = ns.size def OnEvent(ns, e): return def fixed_update(ns): pass def JoyStick(ns, w, h): zp=0 xs=1.0 ys=1.0 a=1.0 spd=1.0 par=ns sz=Size(64, 64) if w > 700 else Size(40, 40) pos=Point(0,0) if w > h: if w > 700: pos=Point(w-w/8, h/6) else: pos=Point(w-w/8, h/6) elif w < h: if w > 700: pos=Point(w-w/6, h/8) else: pos=Point(w-w/6, h/10) col=(0.91, 0.99, 1.0, 1.0) bm=0 ap=Point(0.5, 0.5) TJoy=cache['overlay-joy'] if 'joy' in UIObjects.keys() and UIObjects['overlay'].size != size: UIObjects['joy'].remove_from_parent() UIObjects.pop('joy', None) elif 'joy' not in UIObjects.keys(): UIObjects['joy']=None else: return True UIObjects['joy']=UIComponent(TJoy, position=pos, z_position=zp, alpha=a, speed=spd, parent=par, size=sz, color=col, blend_mode=bm, anchor_point=ap) def Overlay(ns, w, h): pos=Point(0.0, 0.0) zp=0 xs=1.0 ys=1.0 a=1.0 spd=1.0 par=ns sz=Size(w, h) col=(0.91, 0.99, 1.0, 1.0) bm=0 ap=Point(0.0, 0.0) TLand=cache['overlay-land'] TPort=cache['overlay-port'] if 'overlay' in UIObjects.keys() and UIObjects['overlay'].size != size: UIObjects['overlay'].remove_from_parent() UIObjects.pop('overlay', None) elif 'overlay' not in UIObjects.keys(): UIObjects['overlay']=None else: return True if w < h: if w<700: sz=Size(w, h/10*8) UIObjects['overlay']=UIComponent(TPort, position=pos, z_position=zp, alpha=a, speed=spd, parent=par, size=sz, color=col, blend_mode=bm, anchor_point=ap) elif w > h: UIObjects['overlay']=UIComponent(TLand, position=pos, z_position=zp, alpha=a, speed=spd, parent=par, size=sz, color=col, blend_mode=bm, anchor_point=ap) def AddUIObject(ns, k, v): ns.add_child(v) UIObjects[k]=v ns.tally.components+=1 def refresh(ns, ss): w, h=ss ns.Overlay(w, h) ns.JoyStick(w, h) class Loop(Scene): __name__='GameLoop' def setup(ns): ns.background_color=(0.5, 0.5, 0.5) PopulateCache() for k,v in cache.items(): cache[k]=Texture(v) ns.canvas=UICanvas(ns) ns.canvas.refresh(ns.size) ns.add_child(ns.canvas) ns.actor1=Actor(cache['hitbox-solid'], 'test, Actor1', size=ns.size/10, position=ns.size/2, parent=ns) ns.tally=Tally(['gameobjects', 'giobjects', 'resources', 'mobs', 'npcs']) def update(ns): pass def did_change_size(ns): SCREEN_RESOLUTION=ns.size ns.canvas.refresh(ns.size) def AddObject(ns, k, v): pass def LoadWorld(ns): pass
-
-
I have a quick question about:
- The color properties are convenient, but return None if the defaults are not changed. I would prefer just to use a "reverse" color function, see below. It would have the additional benefit of removing the extra internal variables.
def _py_color(self, objc_color): return tuple([c.floatValue() for c in objc_color.arrayFromRGBAComponents()])
Specifically what you were referring to witharrayFromRGBAComponents()
I wasn’t able to find a reference to it anywhere. Also i’m assuming that by objc_color I would be passing in the UIColor that _objc_color creates.
Scratch that, Turns out I was passing in the wrong thing.Thank you everyone for all the help, I really appreciate it!
-
I have just edited the post with a new version. (v0.3 as i’m calling it).
-
@Samer, thanks.
Couple of bug fixes:
- After moving
changePage
out of the init, it no longer works, becausetarget
goes out of scope. This is easily fixed by retaining the reference, i.e. usingself.target
everywhere where you hadtarget
, or maybeself._target
since it is not exactly part of your component's "public API". - As a property,
tint_color
conflicts withui.View
's property by the same name, so we need to give it another name. To make them easier to remember and consistent, I would suggestindicator_tint_color
andindicator_current_color
– a bit verbose, but maybe we prioritize clarity over brevity as they are unlikely to be typed a lot. - On phones the control moves just outside the screen when the phone is rotated – and even if we change the multiplier to a smaller value, there is a chance that it hits the "swipe up from the bottom" indicator that the phones have. To make this more robust, I suggest we let iOS handle it by:
- Using the safe area to avoid any on-screen extras.
- Letting the control calculate the right size for it.
... but especially the safe area is a bit tricky, let me get back to you when I have more time.
Simplification suggestions:
-
contentOffset
looks to me like maybe outdated code, as it is set to 0, inadd_subview
and is thus harmless, but if developer set the view'sx
to some value before adding the subview, would cause problems. Recommend removing if you did not have some specific idea for it. -
ui.View
s have a handy feature where they apply all keyword arguments toself
. Combined with having properties this means that we do not need to explicitly include those in our init parameters, or have theif
sections of code, as long as we add**kwargs
to the init parameters and callsuper().__init__(**kwargs)
somewhere in the init code. (I use this so often that I have made it into a Pythonista snippet.) In the case of your component, thesuper()...
call needs to be at the end of the init to haveself.pageControl
created and available. -
Not a necessary change for this code, but an idea for future projects: the same keyword-setting idea works with other views, so you could avoid typing
self.scrollView
n times and make the ScrollView creation more readable by changing it to:self.scrollView = ui.ScrollView( delegate=self, paging_enabled=True, shows_horizontal_scroll_indicator=False, bounces=False, frame=self.bounds, flex='WH', )
-
Also, note the suggestion on how to set the
frame
andflex
above – this removes the need to manually keep the ScrollView's size in sync with your component in thelayout
method. (Also used so often that I have snippet for theframe
/flex
combo.)
And feature suggestions:
- Exposing a
page_changed
callback with adelegate
parameter forself
. - Including a
set_page
method to scroll to a specific page programmatically. To keep things neat,changePage
could also call the same method. - Adding a property to hide the control when there is only one page (
hidesForSinglePage
), and going against Apple, I would say the default should be True.
- After moving
-
@Samer, ok, here’s how to set the control size and position safely on all devices:
safe_bottom = self.bounds.max_y - self.objc_instance.safeAreaInsets().bottom size = self.pageControl.sizeForNumberOfPages_( self.pageControl.numberOfPages()) self.pageControl.frame = CGRect( (self.bounds.center().x - size.width/2, safe_bottom - size.height), (size.width, size.height))