How to track a zooming UIScrollView? - ios

I have a zooming UIScrollView, and a non-zooming overlay view on which I animate markers. These markers need to track the location of some of the content of the UIScrollView (similar to the way a dropped pin needs to track a spot on the map as you pan and zoom).
I do so by triggering an update of the overlay view in response to the UIScrollView's layoutSubviews. This works, and the overlay tracks perfectly when zooming and panning.
But when the pinch gesture ends the UIScrollView automatically performs a final animation, and the overlay view is out of sync for the duration of this animation.
I made a simplified project to isolate this problem. The UIScrollView contains an orange square, and the overlay view displays a 2-pixel red outline around the frame of this orange square. As you can see below, the red outline always moves to where it should be, except for a short period of time after touch ends, when it visibly jumps ahead to the final position of the orange square.
The full Xcode project for this test is available here: https://github.com/Clafou/ScrollviewZoomTrackTest but all the code is in the two files shown below:
TrackedScrollView.swift:
class TrackedScrollView: UIScrollView {
#IBOutlet var overlaysView: UIView?
let square: UIView
required init(coder aDecoder: NSCoder) {
square = UIView(frame: CGRect(x: 50, y: 300, width: 300, height: 300))
square.backgroundColor = UIColor.orangeColor()
super.init(coder: aDecoder)
self.addSubview(square)
self.maximumZoomScale = 1
self.minimumZoomScale = 0.5
self.contentSize = CGSize(width: 500, height: 900)
self.delegate = self
}
override func layoutSubviews() {
super.layoutSubviews()
overlaysView?.setNeedsLayout()
}
}
extension TrackedScrollView: UIScrollViewDelegate {
func viewForZoomingInScrollView(scrollView: UIScrollView) -> UIView? {
return square
}
}
OverlaysView.swift:
class OverlaysView: UIView {
#IBOutlet var trackedScrollView: TrackedScrollView?
let outline: CALayer
required init(coder aDecoder: NSCoder) {
outline = CALayer()
outline.borderColor = UIColor.redColor().CGColor
outline.borderWidth = 2
super.init(coder: aDecoder)
self.layer.addSublayer(outline)
}
override func layoutSubviews() {
super.layoutSubviews()
if let trackedScrollView = self.trackedScrollView {
CATransaction.begin()
CATransaction.setDisableActions(true)
let frame = trackedScrollView.convertRect(trackedScrollView.square.frame, toView: self)
outline.frame = CGRectIntegral(CGRectInset(frame, -3, -3))
CATransaction.commit()
}
}
}
Among the things I tried was using a CADisplayLink and presentationLayer and this allowed me to animate the overlay, but the coordinates that I obtained from presentationLayer lagged slightly behind the actual UIScrollView, so this still didn't look right. I think the right approach would be to tie my overlay update to the system-created UIScrollView animation, but I haven't had success hacking this so far.
How can I update this code to always track the UIScrollView's zooming content?

UIScrollView sends scrollViewDidZoom: to its delegate in its animation block, if it's “bouncing” back to its minimum or maximum zoom when the pinch ends. Update the frames of your overlays in scrollViewDidZoom: if zoomBouncing is true. If you're using Auto Layout call layoutIfNeeded.
scrollViewDidZoom: is only called once during the zoom bounce animation but adjusting your frames or calling layoutIfNeeded will ensure these changes are animated along with the zoom bounce, thus keeping them perfectly in sync.
Demo:
Fixed sample project: https://github.com/mayoff/ScrollviewZoomTrackTest

Related

Child UIViews unable to receive touch events

I have a UIViewController that embeds a UITableView. This table view is 3/4 the size of the entire screen in height. the remaining 1/4th of the UIViewController has a rounded UIButton that triggers a new UIView on top of the parent view. (UITableView).
Upon instantiating and calling a UIView with a background that is set to:
self.backgroundColor = UIColor(black: 1, alpha: 0.5) it would normally fill the entire view with a black see-through background that will then have an additional UIView with the following constraints:
Leading: 10
Trailing: 10
Top: 50
Bottom: 50
this, in turn, gives me a 'Card' effect on top of the tableView. This 'Card' view then has a UITextview property that is supposed to show the keyboard when the user taps the view with the textview embedded.
The Problem:
Upon selecting the UITextview, or even touching this 'Card' view, the background table is being selected and interacted with. Neither the 'Card' textfield raises the keyboard nor does it make itself solely interactive as the parent table controller seems to be getting the touch events.
Is there any solution to this problem that is encountered in iOS 11? I never experienced this issue in iOS 10. I am using iPhone X for the further note on my issue.
Here is an example of the actual issue occurring.
NOTE: If I were to select the dark area of the card itself, the table view would receive touch events but not the text area within the card view. The reason this card view has the keyboard showing is that I called it manually in code with the textview.becomeFirstResponder method.
ADDITIONAL NOTES: I have also enabled the isUserInteractionEnabled = false on the overall presenting child view. Still does not work and parent view receives touches only.
Code for the Card
class Card: UIView {
// instantiating the textview
var textview = UITextview()
var cardView = UIView()
private override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor(black: 1, alpha: 0.5)
self.cardView.backgroundColor = UIColor.white
self.cardView.layer.cornerRadius = 8
// Just demo the textview
self.textview.frame = CGRect(x: 10, y: 100, width: 300. height: 100)
self.addSubview(cardView) // adding the cardView as a subview to the background colored view
cardView.addSubview(textview)
}
required init?(coder aDecoder: NSCoder) {
fatalError("Error")
}
private func ConstrainCardWith() {
// Constraints
}
}
extension UIView {
func Width()-> CGFloat {
return UIScreen.main.bounds.size.width
}
func Height()-> CGFloat{
return UIScreen.main.bounds.size.height
}
}
The question is your container view(cardview) has no frame, so your child view(textview) can not be touch cause it is out of bounds, so just add some frame in your card view will solve this question

UITextField border with AutoLayout is flickering

I am designing screen like a form, containing few UITextFields using AutoLayout. I wanted to set border only at bottom of the UITextFields. I have set border using CALayer. But UITextField occupies its height(after autolayout is applied to it) in method viewDidAppear, so adding border to UITextField in viewDidAppear makes it appear as if its flickering. So Is there any other way to set border to UITextFeild at bottom with AutoLayout.
class CustomTextField: UITextField {
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)!
self.commonInit()
}
override init(frame: CGRect) {
super.init(frame: frame)
self.commonInit()
}
func commonInit() {
self.borderStyle = .none //To remove default border.
let bottomBorder = UIView()
bottomBorder.frame.size = CGSize(width: self.frame.size.width, height: 1)
bottomBorder.frame.origin = CGPoint(x: 0, y: self.frame.size.height - 1)
bottomBorder.backgroundColor = UIColor.lightGray
bottomBorder.autoresizingMask = [.flexibleWidth, .flexibleTopMargin]
self.addSubview(bottomBorder)
}
}
Finally i achieved it by creating CustomUITextField with AutoLayout. Just apply above class to your UITextField in interface builder.
If I'm understanding you, all you have to do to solve your problem is to call the method "to draw the border" in the method above instead of viewDidAppear. I guess it can sove your problem.
override func viewDidLayoutSubviews() {
//A UIViewController's overrode method
//call you method here
}
This method is called before the view appears, and after the layout of the subviews. When you're working - changing I mean - layers, you should always use it instead of viewDidLoad, for example.
Hope it helps :)
Steps - Select the textfield -> show attribute inspector right panel and select dotted border style .
Next drag the uilabel into the scene and make it 1.0 and width what do you want, keep the bottom of the textfield. So your problem is solved. This may help you.
If you are using the simulator to test that. It looks like its flickering but its not.
If you are scaling the simulator to 25%. The 1px lines appears and
disappears when you scroll cause the screen resolution you have is
less than the real device resolution.
Test it while scaling the simulator to 100%. cmd+1

How do you update UIView layout when using UIKit Dynamics?

I have a custom UIView using UIKit Dynamics to perform an animation when the user taps on a button. The view in question is a simple one, that I lay out manually in layoutSubviews(). However, layoutSubviews() gets called for each frame of animation while UIKit Dynamics are in action, and any layout changes I make in that time (responding, for instance, to a taller status bar) result in distortion of my dynamic views.
How can I respond to a change in view size while a UIKit Dynamics animation is in progress?
Update
I created a demo project (which very closely matches my use case, though it's stripped down), and posted it on GitHub. The storyboard uses AutoLayout, but the view opts out of AutoLayout for laying out its own subviews with translatesAutoresizingMaskIntoConstraints = false. To reproduce the behavior, run in the simulator (I chose iPhone 5) and then hit ⌘Y as the star swings to witness the distortion. This is the view code:
import UIKit
class CustomView: UIView {
var swingingView: UIView!
var animator: UIDynamicAnimator!
var attachment: UIAttachmentBehavior!
var lastViewFrame = CGRectZero
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.translatesAutoresizingMaskIntoConstraints = false
swingingView = UIImageView(image: UIImage(named: "Star"))
self.addSubview(swingingView)
}
override func layoutSubviews() {
// Don't run for every frame of the animation. Only when responding to a layout change
guard self.frame != lastViewFrame else {
return
}
lastViewFrame = self.frame
swingingView.frame = CGRect(x: 0, y: self.frame.size.height / 2, width: 100, height: 100)
// Only run this setup code once
if animator == nil {
animator = UIDynamicAnimator(referenceView: self)
let gravity = UIGravityBehavior(items: [swingingView])
gravity.magnitude = 1.5
animator.addBehavior(gravity)
attachment = UIAttachmentBehavior(item: swingingView,
offsetFromCenter: UIOffset(horizontal: 0, vertical: swingingView.frame.size.height / -2),
attachedToAnchor: CGPoint(x: self.bounds.size.width / 2, y: 0))
attachment.length = CGFloat(250.0)
animator.addBehavior(attachment)
}
animator.updateItemUsingCurrentState(swingingView)
}
}
You should use func updateItemUsingCurrentState(_ item: UIDynamicItem) per the UIDynamicAnimator class reference
A dynamic animator automatically reads the initial state (position and
rotation) of each dynamic item you add to it, and then takes
responsibility for updating the item’s state. If you actively change
the state of a dynamic item after you’ve added it to a dynamic
animator, call this method to ask the animator to read and incorporate
the new state.
The issue is that you are re-setting the frame of a view that has been rotated (i.e. that has a transform applied to it). The frame is the size of the view within the parent's context. and thus you are unintentionally changing the bounds of this image view.
This issue is compounded by the fact that you're using the default content mode of .ScaleToFill and thus when the bounds of the image view change, the star is getting (further) distorted. (Note, the image wasn't square to start with, so I'd personally use .ScaleAspectFit, but that's up to you.)
Anyway, you should be able to remedy this problem by (a) setting the frame when you first add the UIImageView to the view hierarchy; (b) do not change the frame in layoutSubviews, but rather just adjust the center of the image view.

Animate/zoom a focused custom view in tvOS

I have a custom UICollectionViewCell in my tvOS app. It has a UIImageView and some UILabels in it. I can get the cell to be focused by implementing the UIFocusEnvironment protocol without any issue, but I can't figure out how to give my custom cell the focused appearance. (Elevation and responding to user movement on the touchpad).
I'm aware of UIImageView's adjustsImageWhenAncestorFocused property, but that only elevates the image in my cell, not the entire cell.
Is there a way to make tvOS apply the (seemingly) standard focus appearance/behavior to my custom view or do I have to do it all manually?
Thanks in advance.
Update in tvOS 12.0+: Check out new classes Apple has provided in TVUIKit! You can now make custom views that have this focusing behavior!
————
I asked the same question on the Apple developer forums. Apple staff answered:
For custom views you'll have to implement the focus appearance
yourself. In the focus update method you can do things like apply a
transform and use the UIMotionAffect API.
- (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context withAnimationCoordinator:(UIFocusAnimationCoordinator *)coordinator {
if (context.nextFocusedView == self) {
// handle focus appearance changes
}
else {
// handle unfocused appearance changes
}
}
I think it'd be pretty helpful to make a UIView extension to be able to apply the same behavior to any custom view.
Maybe they'd like for us to implement more interesting ways to display focus to the user? That'd be a good reason to enable this easily only for UIImageView (Not to mention that this behavior also adds simulated light over the UIImageView, which is beautiful, but maybe only makes sense for images).
As specified in previous answers, there is no standard way, however there are 3 options for you:
(RECOMMENDED) Implement your own custom focus behaviour , that is similar to UIImageView tilting likewise:
class MotionView: UIView {
let motionEffectGroup = UIMotionEffectGroup()
override init(frame: CGRect = .zero) {
super.init(frame:frame)
self.backgroundColor = .red
addFocusMotionEffect()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func addFocusMotionEffect() {
let min = CGFloat(-15)
let max = CGFloat(15)
let xMotion = UIInterpolatingMotionEffect(keyPath: "layer.transform.translation.x", type: .tiltAlongHorizontalAxis)
xMotion.minimumRelativeValue = min
xMotion.maximumRelativeValue = max
let yMotion = UIInterpolatingMotionEffect(keyPath: "layer.transform.translation.y", type: .tiltAlongVerticalAxis)
yMotion.minimumRelativeValue = min
yMotion.maximumRelativeValue = max
motionEffectGroup.motionEffects = [xMotion,yMotion]
self.addMotionEffect(motionEffectGroup)
}
func removeFocusMotionEffect() {
UIView.animate(withDuration: 0.5) {
self.removeMotionEffect(self.motionEffectGroup)
}
}
Make UIImageView to be dominant View in your cell contentView and then append your custom view to imageView's overlayContentetView so that your customView will animate alongside your UIImageView as follows:
self.imageView.overlayContentView.addSubview(logoView)
Add suitable element from TVUIKit that has the behaviour, currently as of 2022, the TVCardView servers purpose well. Then add CardView as subview of your UICollectionViewCell on top of UIImageView or TVPosterView and it will coordinate its animations with them. You need to add your custom View as subview of TVCardViews contentView. The downfall is that TVCardView cannot really have clear background and you also canot change its round corners.
class CardView: TVCardView{
override init(frame: CGRect = .zero) {
super.init(frame:frame)
let lbl = UILabel(frame: CGRect(x: 0, y: 0, width: 100, height: 50))
lbl.text = "Test Label"
self.contentView.addSubview(lbl)
self.cardBackgroundColor = .red
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

Animate `backgroundColor` of a `UIView` that implements `drawRect`

I have a custom UIView and I would like to animate its backgroundColor property. This is an animatable property of a UIView.
This is the code:
class ETTimerUIView: UIView {
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
// other methods
func flashBg() {
UIView.animateWithDuration( 1.0, animations: {
self.backgroundColor = UIColor.colorYellow()
})
}
override func drawRect() {
// Something related to a timer I'm rendering
}
This code causes causes the animation to skip and the color to change immediately:
self.backgroundColor = UIColor.colorYellow() // Changes immediately to yellow
If I animate alpha, this animates from 1 to 0 over one second as expected:
self.alpha = 0 // animates
How do I animate a background color change in this situation?
Implementing drawRect blocks backgroundColor animation, but no answer is provided yet.
Maybe this is why you can't combine drawRect and animateWithDuration, but I don't understand it much.
I guess I need to make a separate view--should this go in the storyboard in the same view controller? programmatically created?
Sorry, I'm really new to iOS and Swift.
It is indeed not working when I try it, I had a related question where putting the layoutIfNeeded() method inside the animation worked and made the view smoothly animating (move button towards target using constraints, no reaction?). But in this case, with the backgroundColor, it does not work. If someone knows the answer I will be interested to know.
But if you need a solution right now, you could create a UIView (programmatically or via the storyboard) that is used only as a container. Then you add 2 views inside : one on top, and one below, with the same frame as the container. And you only change the alpha of the top view, which let the user see the view behind :
class MyView : UIView {
var top : UIView!
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.blueColor()
top = UIView(frame: CGRectMake(0,0, self.frame.width, self.frame.height))
top.backgroundColor = UIColor.yellowColor()
self.addSubview(top)
}
override func touchesBegan(touches: NSSet, withEvent event: UIEvent) {
let sub = UIView(frame: CGRectMake(0,0, self.frame.width, self.frame.height))
sub.backgroundColor = UIColor.purpleColor()
self.sendSubviewToBack(sub)
UIView.animateWithDuration(1, animations: { () -> Void in
self.top.alpha = 0
}) { (success) -> Void in
println("anim finished")
}
}
}
The answer is that you cannot animate backgroundColor of a view that implements drawRect. I do not see docs for this anywhere (please comment if you know of one).
You can't animate it with animateWithDuration, nor with Core Animation.
This thread has the best explanation I've found yet:
When you implement -drawRect:, the background color of your view is then drawn into the associated CALayer, rather than just being set on the CALayer as a style property... thus prevents you from getting a contents crossfade
The solution, as #Paul points out, is to add another view above, behind, or wherever, and animate that. This animates just fine.
Would love a good understanding of why it is this way and why it silently swallows the animation instead of hollering.
Not sure if this will work for you, but to animate the background color of a UIView I add this to a UIView extension:
extension UIView {
/// Pulsates the color of the view background to white.
///
/// Set the number of times the animation should repeat, or pass
/// in `Float.greatestFiniteMagnitude` to pulsate endlessly.
/// For endless animations, you need to manually remove the animation.
///
/// - Parameter count: The number of times to repeat the animation.
///
func pulsate(withRepeatCount count: Float = 1) {
let pulseAnimation = CABasicAnimation(keyPath: "backgroundColor")
pulseAnimation.fromValue = <#source UIColor#>.cgColor
pulseAnimation.toValue = <#target UIColor#>.cgcolor
pulseAnimation.duration = 0.4
pulseAnimation.autoreverses = true
pulseAnimation.repeatCount = count
pulseAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
self.layer.add(pulseAnimation, forKey: "Pulsate")
CATransaction.commit()
}
}
When pasting this in to a source file in Xcode, replace the placeholders with your two desired colors. Or you can replace the entire lines with something like these values:
pulseAnimation.fromValue = backgroundColor?.cgColor
pulseAnimation.toValue = UIColor.white.cgColor

Resources