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 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
-
@Samer, what @stephen said. It is a method you override in your ui.View subclass to react to changes in the view's size, usually due to rotation.
-
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))
-
size.width / 2 --> size.width // 2 to deal with size.width being odd?
-
@ccc, screen coordinates are floats.
-
@mikael Thank you again for this feedback.
What do you mean by:
- Exposing a
page_changed
callback with adelegate
parameter forself
.
I have not played around with callbacks very much and would appreciate some guidance on how to implement something like this.
- Exposing a
-
@Samer, I guess it sounds grander than it is.
What I meant is that in the
scrollview_did_scroll
method, you could check ifself
has an attributedelegate
, and if that object has a callable attribute calledpage_changed
, then we call that with some suitable parameters. Following the other Pythonista UI classes, parameters could beself
andpage_number
.scrollview_did_scroll
gets called a lot, not just once per page, so we would need to keep track of the previous page number and only call the delegate when there is a change.End result, people can set a delegate on your component to get notified when the page changes.
-
@mikael are you talking about somthing like this?
class MyDel: def __init__(self, sv): self.sv=sv self.current_page=sv.current_page self.prev_page=None self._refresh() def page_changed(self, scroll_view, page): pass def _refresh(self): if self.sv: if self.current_page != self.sv.current_page: self.page_changed(self.sv, self.sv.current_page) self.prev_page=self.current_page self.current_page=self.sv.current_page self.sv.refresh() class MyScroll(ui.View): def __init__(self): self.delegate=MyDel(self) self.current_Page=0 def _refresh(self): if self.delegate: self.delegate.refresh()
-
@stephen, not quite. The delegate (or the developer using the page control component) should not need to think about whether the page has changed; it only gets a notification when the page has changed.
Thus no
_refresh
, no book-keeping in the user's side, just aself.previous_page
to compare to in thescrollview_did_scroll
method of @Samer's component.
-
@mikael ok i didnt click we are using an already exsisting callback method. i was thinking of a Base Class form implemention.
so like this?
class MyDel: def __init__(self, sv): self.current_page=sv.current_page self.prev_page=None def page_changed(self, sv, new-page): pass def scrollview_did_scroll(self, scrollview): if self.sv == scrollview: if self.current_page != scrollview.current_page: self.page_changed(scrollview, scrollview.current_page) self.prev_page=self.current_page self.current_page=self.sv.current_page self.sv.refresh() class MyScroll(ui.View): def __init__(self): self.delegate=MyDel(self) self.current_Page=0
but what happends if
self.current_page
changes with no call to delegatescrollview_did_scroll
due to button activation or key entry? wouldnt that delay any needed action on a method, for example, calledpage_opend
orpage_should_open
?i dont mean to over think i just want to make sure i understand completely 😅
EDIT:
wnted to clerify i do understand you mean use self as delegate and not other object i just separated for clearity. 😊
-
I just edited the post with v0.4. Let me know what you people think!
-
@Samer Good work brother 😊 is this for exploration of
ui
module or project?
-
@stephen A bit of both, I have a project in the works that started out as a way to explore the ui module, it then morphed into learning about the objc_utils module a bit (MapView, ProgressView, UIPageControl) and then finally into wanting to create an app for the app store. This is just one part of a larger project. However I have probably had the most fun on this one part then all the others combined.
-
@Samer, good job!
Just a few more things come to mind. Seems like this fun is coming to an end.
- Recommend adding an explicit call to
self.layout()
at the end ofadd_subview
. Layout does get called without it, but only later, triggered by the UI loop, which can cause flickering when a view is momentarily visible in the wrong place. _trigger_delegate
is called both inset_page
andscrollview_did_scroll
. Sinceset_page
sets thecontent_offset
which then triggersscrollview_did_scroll
, you should not need to call_trigger_delegate
inset_page
at all.- Also, it would make more sense to me to call
_trigger_delegate
last inscrollview_did_scroll
, after the new page number has been set. The way it is now works only, I think, because there is so many scrolling events in every move from page to page. - Recommend moving
sampleDelegate
(should be upper case) andexample
within theif __name__
block. That way they do not unnecessarily get imported in "prudction use", and pollute the component's namespace.
- Recommend adding an explicit call to
-
@mikael And with that all the changes are implemented.
-
@Samer, tried it out, just to get the ”developer experience”.
image_names = ['test:Boat', 'test:Lenna', 'test:Mandrill', 'test:Peppers'] pages = PageControl( background_color='black', indicator_tint_color='grey', indicator_current_color='white' ) for image_name in image_names: pages.add_subview(ui.ImageView( image=ui.Image(image_name), content_mode=ui.CONTENT_SCALE_ASPECT_FIT)) pages.present('fullscreen', hide_title_bar=True )
Nice and tight, with nothing extra.
But, noticed a problem where on the first scroll the images jump down a little bit. I also tried changing the content size to the actual vertical content size, but then ended up having vertical scrolling, and images below the vertical center.
@Samer, which device are you using, are you seeing the same?
-
This post is deleted!last edited by