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.
Joystick UIControl ui wrapper?
-
Inspired by @DoinStuffMobile request for a Joystick-like control for the scene module, I created a very basic joystick using the objc_util module. I’ve gotten it working to use custom actions, pass a few kwargs in, and such things without crashing.
I was curious as to if there’s a way to wrap the objc_instance of the Joystick with the ui module similar to how @omz has with the other ui views?
Also, I need suggestions! What should I change to be more efficient, elegant, pythonic, etc? What are some things you guys think I should add?
Code is kinda long:
from objc_util import * import ui class Joystick (ui.View): def __new__(cls,**kwargs): inst=create_objc_class( 'UIJoystick', ObjCClass('UIControl'), methods=[Joystick.beginTrackingWithTouch_withEvent_,Joystick.continueTrackingWithTouch_withEvent_,Joystick.endTrackingWithTouch_withEvent_,Joystick.action] ).new() Joystick.__init__(inst,**kwargs) return inst def __init__(self,**kwargs): for key,val in kwargs.items(): if key =='action': self.customAction=val elif eval(f'self.{key}()').__class__ != self.aabbbbbbb.__class__: key=key[0].upper()+key[1:] if key == 'Frame': val=CGRect(CGPoint(val[0],val[1]),CGSize(val[2],val[3])) elif key == 'BackgroundColor': val=ObjCClass('UIColor').alloc().initWithRed_green_blue_alpha_(val[0],val[1],val[2],val[3]) eval(f'self.set{key}_(val)') #can add more kwargs self.radius=self.size().height/2 self._setCornerRadius_(self.radius) stick=ui.View(bg_color='#acacac') stick.touch_enabled=False stick.frame=( 0, 0, self.size().width, self.size().height) self.originalStickCenter=stick.center stick.corner_radius=self.size().width/2 self.addSubview_(stick.objc_instance) self.layer().setBorderColor_(ObjCClass('UIColor').alloc().initWithRed_green_blue_alpha_(0.67, 0.67, 0.67,1.0).CGColor) self.layer().setBorderWidth_(1) return @staticmethod def beginTrackingWithTouch_withEvent_(_self,_cmd,touch,event): #called when a touch enters #control's bounds return True @staticmethod def continueTrackingWithTouch_withEvent_(_self,_cmd,touch,event): #called when a touch event is #updated. like dragging #add code self=ObjCInstance(_self) touchLoc=ObjCInstance(touch).preciseLocationInView_(self) stick=self.subviews()[0] touchVec=CGVector( touchLoc.x-self.radius, touchLoc.y-self.radius) touchVecMagn=(touchVec.dx**2 + touchVec.dy**2)**.5 unitTouchVec=CGVector( touchVec.dx/touchVecMagn, touchVec.dy/touchVecMagn) if touchVecMagn < self.radius and touchLoc.x < self.size().width and touchLoc.y < self.size().height: newLoc=touchLoc else: newLoc=CGPoint( self.radius*unitTouchVec.dx+self.radius, self.radius*unitTouchVec.dy+self.radius) stick.setCenter_(newLoc) self.touchVec=touchVec self.sendAction_to_forEvent_('action',self,None) return True @staticmethod def endTrackingWithTouch_withEvent_(_self,_cmd,touch,event): #called when touch ends #add code touch=ObjCInstance(touch) self=ObjCInstance(_self) stick=self.subviews()[0] stick.setCenter_(CGPoint(self.originalStickCenter.x,self.originalStickCenter.y)) return True @staticmethod def action(_self,_cmd): self=ObjCInstance(_self) self.customAction.__call__(ObjCInstance(_self)) return if __name__=='__main__': x,y=ui.get_screen_size() root=ui.View( frame=(0,0,x,y), bg_color='white') def closeRoot(sender): root.close() return closeButton=ui.Button( frame=(50,75,24,24), image=ui.Image('iob:close_24'), tint_color='#ff0000', action=closeRoot ) def moveButton(sender): btn=sender.superview().subviews()[1] btn.setCenter_(CGPoint(btn.center().x+sender.touchVec.dx//10,btn.center().y+sender.touchVec.dy//10)) return j=Joystick( frame=(x//2-125,y//2-200,250,250), action=moveButton ) root.objc_instance.addSubview_(j) root.add_subview(closeButton) root.present('popover',hide_title_bar=True)
-
Dude... you're amazing. I need to learn to figure things out like this! I'll definitely be tinkering with how to use and understand this :D
-
@DoinStuffMobile glad you like it 😂 the only kwargs it can take right now are frame, backgroundColor, and action, so be wary of that.
I’m working to get a few more things for it to recognize, but the wrapper (hopefully) will handle most of those. -
Turns out I like doing stuff the hard way, and all the capabilities to make a joystick already exist in the ui module. I’ve redone the code, and now it’s fully wrapped by the ui module and a ton easier to understand.
import ui class Joystick (ui.View): def __init__(self,**kwargs): self.action=None self.stick=ui.View(name='stick') self._tint_color=None for key,val in kwargs.items(): if key=='frame' and val[2]!=val[3]: raise ValueError('Joystick must be square') setattr(self,key,val) if not self.bg_color: self.bg_color='#ffffff' if not self.tint_color: self.tint_color='grey' self.radius=self.width/2 self.corner_radius=self.radius self.stick.corner_radius=self.corner_radius self.stick.touch_enabled=False self.stick.frame=(0,0,self.width,self.height) self.originalPosition=self.stick.center self.objc_instance.setClipsToBounds_(False) self.add_subview(self.stick) return def touch_moved(self,touch): touch=touch.location touchVec=ui.Vector2( touch.x-self.radius, touch.y-self.radius) vecMagn=(touchVec.x**2+touchVec.y**2)**.5 if vecMagn < self.radius and touch.x < self.width and touch.y < self.height: newLoc=touch else: unitVec=ui.Vector2( touchVec.x/vecMagn, touchVec.y/vecMagn) newLoc=ui.Point( self.radius*unitVec.x+self.radius, self.radius*unitVec.y+self.radius) self.stick.center=newLoc.as_tuple() if self.action: self.action.__call__(self,touchVec) return def touch_ended(self,touch): self.stick.center=self.originalPosition return @property def tint_color(self): return self._tint_color @tint_color.setter def tint_color(self,value): self._tint_color=value self.stick.bg_color=value return if __name__=='__main__' : x,y=ui.get_screen_size() j=Joystick(name='joystick',tint_color='red') j.frame=(x//2-50,y//2-150,100,100) root=ui.View(frame=(0,0,x,y)) root.bg_color='white' def closeRoot(sender): sender.superview.close() return btn=ui.Button(name='button') btn.frame=(50,50,25,25) btn.image=ui.Image('iob:close_32') btn.tint_color='red' btn.action=closeRoot label=ui.Label(name='label') label.frame=(x-175,50,200,20) label.text='x,y' def moveButton(self,touchVec): btn=self.superview.subviews[1] label=self.superview.subviews[2] btn.center=( btn.center.x+touchVec.x/10, btn.center.y+touchVec.y/10) xstr=str(btn.center.x).split('.') ystr=str(btn.center.y).split('.') xstr=xstr[0]+'.'+xstr[1][:4] ystr=ystr[0]+'.'+ystr[1][:4] label.text=f'{xstr},{ystr}' return j.action=moveButton root.add_subview(j) root.add_subview(btn) root.add_subview(label) root.present('popover',hide_title_bar=True)
-
@mcriley821, you must be the first who discovered and used objc_util before the ui module. 😁
One pythonic hint is just the styling for readability. If you select ”Check Style” in the tools (”wrench”) menu, you can see there is quite a lot it does not like. Luckily, there is also ”Reformat Code” available – just have ”Apply Style Guide” and PEP8 selected, and you not have to worry about style.
-
@mcriley821, shorter way to format floats:
label_text = f'{btn.center.x:.4f}, {btn.center.y:.4f}'
-
@mikael said:
shorter way to format floats: label_text = f'{btn.center.x:.4f}, {btn.center.y:.4f}'
This is amazing😂😂😂 i can’t tell you how many times I’ve done string slices. No more!
Also, does pythonic just mean style? I always thought pythonic meant being ‘elegant’ (if that makes sense) and efficient. As you can tell from my code, I’m not big on the style lmao. I just want to make sure it’s efficient (time complexity).
-
@mcriley821, style is one part of being elegant. Elegant code is understandable and readable, i.e. not wasting the reader’s time.
No need to use
__call__
, just call it:self.action(self, touchVec)
-
@mcriley821, no need to manually calculate vector magnitude:
vecMagn = (touchVec.x**2 + touchVec.y**2)**.5
... Vector2 has you covered:
vecMagn = abs(touchVec)
-
@mcriley821, no need to spend effort trying to make sure the coordinates are integers:
j.frame = (x // 2 - 50, y // 2 - 150, 100, 100)
Coordinates are floats, and the decimals are even somewhat meaningful on Retina displays.
-
@mcriley821, also no need to navigate to btn and label in moveButton, the variables are already available (in the function’s closure).
-
@mcriley821, instead of:
btn.center = (btn.center.x + touchVec.x / 10, btn.center.y + touchVec.y / 10)
... you should be able to just say:
btn.center += touchVec / 10
... but for some reason it does not seem to exactly work.
-
@mcriley821, ui.View conveniently sets all its kwargs, so we can just call super() with them, no need to iterate manually. Also, defaults can be set before. tint_color we handle separately, as it is a custom property. (In general I would advice against overriding the ui.View properties, it will come back to haunt you.)
def __init__(self, tint_color='grey', **kwargs): kwargs.setdefault('background_color', 'black') super().__init__(**kwargs) ...
-
@mikael I iterate manually to catch a non-square frame. Is there a different way to make sure this happens? I feel like I also need to override the frame setter to catch it after init
-
@mcriley821, sorry, forgot to include that bit. Seems enough to check it after super(). If you want to enforce it continuously, layout() is a good option since, in addition frame, size can change via bounds, width and height.
def __init__(self, tint_color='grey', **kwargs): kwargs.setdefault('background_color', 'black') super().__init__(**kwargs) if self.frame.width != self.frame.height: raise ValueError('Joystick must be square') ...
-
@mcriley821, also makes sense to set self.stick.flex = 'WH', so that it will resize with its superview.
-
@mcriley821, taking the arithmetic capabilities of ui.Point/Vector2, the calculations can collapse to:
def touch_moved(self, touch): touch = touch.location radius_vector = [self.radius]*2 touch_vector = touch - radius_vector magnitude = abs(touch_vector) if magnitude < self.radius: self.stick.center = touch else: self.stick.center = ( touch_vector / magnitude * self.radius + radius_vector ) if self.action: self.action(self, touch_vector) return
Thank you for your patience, I think I am done.
-
@mikael I really appreciate you taking the time to help me and the improvements! I’ve already updated a bunch of stuff from your suggestions and I’m currently working on using the ‘iob:pinpoint' ionicon to add a type of texture to the ‘joystick’!
-
@mcriley821, interesting! Careful placement to get just the head of the pin?
-
@mcriley821, if you need a bit more flexibility, here’s an objc CAGradientLayer version, credits for an original axial version to @cvp:
import ui import objc_util CAGradientLayer = objc_util.ObjCClass('CAGradientLayer') class ShadedCircle(ui.View): def __init__(self, color1='#ff8484', color2='red', **kwargs): super().__init__(**kwargs) o_color1 = objc_util.UIColor.colorWithRed_green_blue_alpha_( *ui.parse_color(color1)).CGColor() o_color2 = objc_util.UIColor.colorWithRed_green_blue_alpha_( *ui.parse_color(color2)).CGColor() layer = self.objc_instance.layer() grlayer = CAGradientLayer.layer() grlayer.setType('radial') grlayer.frame = layer.bounds() grlayer.setColors_([o_color1, o_color2]) layer.insertSublayer_atIndex_(grlayer, 0) grlayer.setStartPoint(objc_util.CGPoint(0.25, 0.25)) grlayer.setEndPoint(objc_util.CGPoint(1.0, 1.0)) grlayer.locations = [0.0, 1.0] def layout(self): self.height = self.width self.corner_radius = self.width / 2 if __name__ == '__main__': v = ui.View() c = ShadedCircle( center=v.bounds.center(), flex='RTBL', ) v.add_subview(c) v.present('fullscreen', animated=False)