Forum Archive

UIPageControl (Paging scrollview with page indicators)

Samer

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 colour
+ m.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 on
+ m.page_count returns total amount of pages
+ Switched from ui.get_screen_size() to using self.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 includes page_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() in add_subview
+ Removed call to _trigger_delegate from set_page, as set_page triggers scrollview_did_scroll which calls _trigger_delegate

v0.6
+ Switched from hasattr to try: 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 and height of each subview manually to using flex
+ Renamed PageControl instance in example to pages from m

mikael

@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.

  1. Move ObjC class creations outside init, to avoid creating them for every instance.
  2. Support adding views without explicitly setting a size or number of pages. Would suggest overriding add_subview for ultimate convenience.
  3. Tweak the previous to support device rotation and arbitrary sized parent ScrollView (set flex on PageControl, override layout).
cvp

@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))  

mikael

@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'))
cvp

@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

mikael

@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'))
cvp

@mikael ok, finally, I did not understand correctly 😢, sorry

Samer

@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.

mikael

@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.

Samer

After a few week break, I have just updated this post with a new version that implements some of the great feedback I got.

mikael

@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 by objc_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 the changePage 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 with super().add_subview().
  • Sizing of the pages would be best based on self.bounds.width and self.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 internal bounds instead of the external frame, just to tolerate the remote possibility of someone adding a transform to your component.)
  • Strongly recommend moving all the placement code to a layout method, where you just iterate through the scrollView subviews(), size them according to self.bounds, place them side-by-side and update content_size. layout gets called on rotation if the component is set to flex, and add_subview can call it directly. This makes the component completely responsive, and the developer can just add_subviews 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 of tintColor. 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, change changePageClass to start with an upper case letter to signal that it is a class.
stephen
  • 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')
Samer

@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.
stephen

@Samer

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 Module

def did_change_size(self):
    pass
stephen

@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



mikael

@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.

Samer

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 with arrayFromRGBAComponents() 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!

Samer

I have just edited the post with a new version. (v0.3 as i’m calling it).

mikael

@Samer, thanks.

Couple of bug fixes:
* After moving changePage out of the init, it no longer works, because target goes out of scope. This is easily fixed by retaining the reference, i.e. using self.target everywhere where you had target, or maybe self._target since it is not exactly part of your component's "public API".
* As a property, tint_color conflicts with ui.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 suggest indicator_tint_color and indicator_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, in add_subview and is thus harmless, but if developer set the view's x to some value before adding the subview, would cause problems. Recommend removing if you did not have some specific idea for it.
* ui.Views have a handy feature where they apply all keyword arguments to self. Combined with having properties this means that we do not need to explicitly include those in our init parameters, or have the ifsections of code, as long as we add **kwargs to the init parameters and call super().__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, the super()... call needs to be at the end of the init to have self.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 and flex above – this removes the need to manually keep the ScrollView's size in sync with your component in the layout method. (Also used so often that I have snippet for the frame/flex combo.)

And feature suggestions:

  • Exposing a page_changed callback with a delegate parameter for self.
  • 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.
mikael

@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))
ccc

size.width / 2 --> size.width // 2 to deal with size.width being odd?

mikael

@ccc, screen coordinates are floats.

Samer

@mikael Thank you again for this feedback.

What do you mean by:
* Exposing a page_changed callback with a delegate parameter for self.

I have not played around with callbacks very much and would appreciate some guidance on how to implement something like this.

mikael

@Samer, I guess it sounds grander than it is.

What I meant is that in the scrollview_did_scroll method, you could check if self has an attribute delegate, and if that object has a callable attribute called page_changed, then we call that with some suitable parameters. Following the other Pythonista UI classes, parameters could be self and page_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.

stephen

@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()

mikael

@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 a self.previous_page to compare to in the scrollview_did_scroll method of @Samer's component.

stephen

@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 delegate scrollview_did_scroll due to button activation or key entry? wouldnt that delay any needed action on a method, for example, called page_opend or page_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. 😊

Samer

I just edited the post with v0.4. Let me know what you people think!

stephen

@Samer Good work brother 😊 is this for exploration of ui module or project?

Samer

@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.

mikael

@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 of add_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 in set_page and scrollview_did_scroll. Since set_page sets the content_offset which then triggers scrollview_did_scroll, you should not need to call _trigger_delegate in set_page at all.
  • Also, it would make more sense to me to call _trigger_delegate last in scrollview_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) and example within the if __name__ block. That way they do not unnecessarily get imported in "prudction use", and pollute the component's namespace.
Samer

@mikael And with that all the changes are implemented.

mikael

@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?

stephen

@mikael said:

CONTENT_SCALE_ASPECT_

i myself am using iPad Air2. i get the same bug you do and i ca get it to stop dropping but then all emages except first are smaller than screen width so i get overlaping..

stephen

@mikael I have Fixed this issue ill post with my changes

stephen

@mikael @Samer

For some reason self.scrollView.y is 20pt too high and it fails to flex H i set frame and flex and issue was corrected. setting y to 19 still drops butbit is hard to see.

self.scrollView = ui.ScrollView(
            delegate=self,
            paging_enabled=True,
            shows_horizontal_scroll_indicator=False,
            bounces=False,
            frame=(0, 20, w, h), flex='h',)
Samer

@mikael huh, thats very strange I get the same effect using your example on my IPad Pro 11” (2018). It seems like initially the view overlaps with the info bar at the top of the screen (time, wifi, battery, etc) and upon touching the screen the view shifts down to not overlap with it. It’s hard to tell in your example due to the black text on black background. However if you set hide_title_bar to true in the built in example a similar effect happens. I will look into a fix in the next day or two.

stephen

@mikael @Samer ill Give a more indepth respone 🤓

this is complete Script with comments and just commenting out changes instead if removing.


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 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 v in self.scrollView.subviews:
            v.width = self.bounds.width
            v.height = self.bounds.height
            v.x = v.pNum * 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, v):
        v.pNum = len(self.scrollView.subviews)
        self.pageControl.numberOfPages = len(self.scrollView.subviews) + 1
        self.scrollView.add_subview(v)
        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):
        if hasattr(self, 'delegate'):
            if hasattr(self.delegate, 'page_changed'):
                if self.pageControl.currentPage() is not self._prev_page:
                    self.delegate.page_changed(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)

    def update(self):
        print('!')

    def present(self, **kwargs): # evaluate input before presenting
        if kwargs['hide_title_bar']:
            self.scrollView.y=20 #if hide_title_bar then adjust scrollView
        super(PageControl, self).present(**kwargs) # Continue..
if __name__ == '__main__':

#   class SampleDelegate():
#       def __init__(self):
#           pass

#       def page_changed(self, page_number):
#           """Gets called every time the page changes."""
#           print(f'Delegate: {page_number}')


#   def btn_action(sender):
#       m.set_page(2)

#   m = 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)
#   sv1B.action = btn_action
#   sv.add_subview(sv1B)

#   sv2 = ui.View()
#   sv2.background_color = 'blue'

#   sv3 = ui.View()
#   sv3.background_color = 'yellow'

#   m.indicator_tint_color = 'brown'
#   m.indicator_current_color = 'black'

#   m.add_subview(sv)
#   m.add_subview(sv2)
#   m.add_subview(sv3)

#   m.delegate = SampleDelegate()

#   print(m.indicator_tint_color)
#   print(m.indicator_current_color)
#   print(m.page_count)
#   print(m.current_page)
#   print(m.hide_on_single_page)

#   m.present('fullscreen

    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(style='fullscreen', 
        hide_title_bar=False
    )


as you can see, just by lowering the scrollView 20pt (i believe tjis is the title bar height), we fix the issue. by placing an override in our PageControl class we check if hide_title_bar and only if True we then lower the y attribute.

I tested and even after changing y the childview's image can still completely cover the screen. this tells myself that somthing behind the scenes is not right. the image should not be able to display above 20 since the parent view is set to 20 for y.

@omz i also noticed that there is an issue if you stack too deep (3 deep in my case) in the UI Builder. App doesnt seem to hold onto the objects data and they disapear when you leave subviews for the given custom View object.

Main View
- sub View
- - sub sub View
- - - datalost...

maybe in this case we might be feling a bit of this with the ImageView we are using?

Obj-c instance
- PageControl
- - ScollView
- - - ImageView

EDIT: what i mean is the imageview might be losing its placement data from creation so it defaults to having a tittle bar, and then when we initiate the event loop we remind it of its placement when delegate for scrolling gets fired..

And ofcourse this is with me asuming two things..
1. objc instance and python are separate intities
2. if so then objc takes persistance over the python view.

hopfully this helps get an idea of whats going on and hopfully im not wrong on these. this is just my perspective and im by no means an expert.

mikael

@stephen, probably related to the 20 pt at the top, yes. I would really like to resolve it in a more generic way than putting in a specific check for the hide_title_bar.

Couple of data points regarding your other observations:
* ImageViews, by design or accident, do not have their clipsToBounds set. This is why the images do not seem to respect the 20 pt boundary.
* I have built many UIs with nesting deeper than 3 layers, and would say probably that is not the cause here.

I suspect there is something around the handling of the safe area that has changed around iOS 11 and has not been really addressed by Pythonista since.

What do you get on iPad if you check the safeAreaInsets for a fullscreen view?

mikael

@stephen, yes, this seems to be a bug or a feature, but not in @Samer’s component.

As a workaround, we can include an additional background view:

background = ui.View(background_color='black')

And then set the PageControl size only after the background has been presented:

background.present('fullscreen', 
    hide_title_bar=True
)

pages.frame = background.bounds
background.add_subview(pages)

No jumping around then.

mikael

@Samer, couple more simplification/maintainability comments, which do not affect functionality.

Forgiveness, not permission

For the delegate calling, the pythonic way is to rely a bit more on the exception mechanism:

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()

This is not in any way inefficient (hasattr relies on catching the same exception), is much more focused on the "good case", and avoids a fair bit of nesting.

Oh, I lied, there is a functionality change there: recommend adding self as the first parameter to the callback. This enables using two or more instances of the component, all tracked with the same delegate, which then gets the information which component was triggered. This is in line how Button actions have a sender, TextView delegates get the textview etc.

Minimize attributes

In add_subview, we add a pNum attribute to the pages. This is unnecessary, as we can change the layout method to simply use the order of subviews:

for i, v in enumerate(self.scrollView.subviews):
    v.x = i * self.bounds.width

This even supports the future option of reorganising the views, without needing to fix the pNum.

Let iOS do the resizing wherever possible

If in add_subview we set the page size and flex:

def add_subview(self, page):
    page.frame = self.scrollView.bounds
    page.flex = 'WH'
    ...

... we do not need to set v.width and v.height explicitly in the layout loop, so it reduces to the simple version above. I imagine native iOS resizing is slightly more efficient as well.

stephen

@mikael

sorry for the delay brotha heres a rundown per call of layout without hardsetting the y to 20

⇨⇨ Results

Samer

I took a few days to think about this bug/feature. I implemented a workaround for it which at the very least hides the problem. I don’t like it, but it works. If anyone has a better solution then overriding the present method, I would be happy to replace my current solution with it.

stephen

@Samer

im sorry i never posted.. you dont need to override present. in your __init__ just add y=20 to your scrollView creation.



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',
            y=20 # <-- here
        )

just one line , 4 characters 🤓

stephen

i only overrided present cuz i tent to make stuff complicated when im tired lol i apologize

Samer

@stephen There are two problems I encountered with that solution, the first is that when hide_title_bar=True The whole view is shifted down 20px. The second is that on my iPad it still jumps down a few pixels, yet it doesn’t on my iPhone. I wish the solution was that simple however it is unfortunately not.

stephen

@Samer Sorry for the late reply. been working on this one all night.

my post

I still dont have the ideal fix for this.. (meaning without overriding present) but am u got three choices..

ONE:

Hard setting scrollView() like before..



self.scrollView = ui.ScrollView(
        delegate=self,
        paging_enabled=True,
        shows_horizontal_scroll_indicator=False,
        bounces=False,
        frame=self.bounds,
        flex='WH',
        y=20 # ⥢⬅ here
    )

TWO:

Hard set inside SetPage..


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, -20) # ⥢⬅ here 
        else:
            raise ValueError("Invalid Page Number. page_number is zero indexing.")


THREE:

Your favorit override..
added an instance variable to it though..


class PageControl(ui.View):
    def __init__(self, **kwargs):

        self.titleBarAdjustment=0 # ⥢⬅ here 

        ...

    #with this guy..
    def present(self, **kwargs): # evaluate input before presenting
            if kwargs['hide_title_bar']:
                self.scrollView.y=20 #if hide_title_bar then adjust scrollView
            super(PageControl, self).present(**kwargs) # Continue..

the second option needs to know if the bar is present otherwise it just has a revers effect to original image issue.. (jumps up)

im still looking for a beter solution for this. the objc methods were not working last night and im sure it was writers error. but im the mean time at least two of these will hold by maybe?


as for your other issues..

Depending on your phone and tabelets they could be explained.

i suspect that it has todo with Retina scaling and resolution. i have an iPhone 6s and iPad Air 2 and they both have a 2x1 Retina Display so i dont get this issue but id imagine if u has a 2x1 phone and 3x1 ipad it would creat this. all you really need to do if thats the case is check device at runtime and setup Layout accordingly. ui/scene, atleast for 2x1, should auto compensate for this but pil doesnt. just for future info lol.. but by using ui.from_data(data, scale) is where u set scaling for retina convertion. this could help if your dispay happends to be a factor. if i find anything ill let you know!