Background
In order to make a text view that scrolls horizontally for vertical Mongolian script, I made a custom UIView subclass. The class takes a UITextView, puts it in a UIView, rotates and flips that view, and then puts that view in a parent UIView.
The purpose for the rotation and flipping is so that the text will be vertical and so that line wrapping will work right. The purpose of sticking everything in a parent UIView is so that Auto layout will work in a storyboard. (See more details here.)
Code
I got a working solution. The full code on github is here, but I created a new project and stripped out all the unnecessary code that I could in order to isolate the problem. The following code still performs the basic function described above but also still has the slow loading problem described below.
import UIKit
#IBDesignable class UIMongolTextView: UIView {
private var view = UITextView()
private var oldWidth: CGFloat = 0
private var oldHeight: CGFloat = 0
#IBInspectable var text: String {
get {
return view.text
}
set {
view.text = newValue
}
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override init(frame: CGRect){
super.init(frame: frame)
}
override func sizeThatFits(size: CGSize) -> CGSize {
// swap the length and width coming in and going out
let fitSize = view.sizeThatFits(CGSize(width: size.height, height: size.width))
return CGSize(width: fitSize.height, height: fitSize.width)
}
override func layoutSubviews() {
super.layoutSubviews()
// layoutSubviews gets called multiple times, only need it once
if self.frame.height == oldHeight && self.frame.width == oldWidth {
return
} else {
oldWidth = self.frame.width
oldHeight = self.frame.height
}
// Remove the old rotation view
if self.subviews.count > 0 {
self.subviews[0].removeFromSuperview()
}
// setup rotationView container
let rotationView = UIView()
rotationView.frame = CGRect(origin: CGPointZero, size: CGSize(width: self.bounds.height, height: self.bounds.width))
rotationView.userInteractionEnabled = true
self.addSubview(rotationView)
// transform rotationView (so that it covers the same frame as self)
rotationView.transform = translateRotateFlip()
// add view
view.frame = rotationView.bounds
rotationView.addSubview(view)
}
func translateRotateFlip() -> CGAffineTransform {
var transform = CGAffineTransformIdentity
// translate to new center
transform = CGAffineTransformTranslate(transform, (self.bounds.width / 2)-(self.bounds.height / 2), (self.bounds.height / 2)-(self.bounds.width / 2))
// rotate counterclockwise around center
transform = CGAffineTransformRotate(transform, CGFloat(-M_PI_2))
// flip vertically
transform = CGAffineTransformScale(transform, -1, 1)
return transform
}
}
Problem
I noticed that the custom view loads very slowly. I'm new to Xcode Instruments so I watched the helpful videos Debugging Memory Issues with Xcode and Profiler and Time Profiler.
After that I tried finding the issue in my own project. It seems like no matter whether I use the Time Profiler or Leaks or Allocations tools, they all show that my class init method is doing too much work. (But I kind of knew that already from the slow load time before.) Here is a screen shot from the Allocations tool:
I didn't expand all of the call tree because it wouldn't have fit. Why are so many object being created? When I made a three layer custom view I knew that it wasn't ideal, but the number of layers that appears to be happening from the call tree is ridiculous. What am I doing wrong?
You shouldn't add or delete any subview inside layoutSubviews, as doing so triggers a call to layoutSubviews again.
Create your subview when you create your view, and then only adjust its position in layoutSubviews rather than deleting and re-adding it.
Related
I am trying to set an underline on my UITextFields. I have tried a couple of methods but none of them seem to work. After looking through a couple of websites, the most suggested method is the following:
extension UITextField {
func setUnderLine() {
let border = CALayer()
let width = CGFloat(0.5)
border.borderColor = UIColor.lightGray.cgColor
border.frame = CGRect(x: 0, y: self.frame.size.height - width, width: self.frame.size.width-10, height: self.frame.size.height)
border.borderWidth = width
self.layer.addSublayer(border)
self.layer.masksToBounds = true
}
}
I can't think of any reason as to why the code above would not work, but all the answers I saw were posted a couple of years ago.
Could someone please let me know what I am doing wrong?
One problem I see with the code that you posted is that it won't update the layer if the text field gets resized. Each time you call the setUnderLine() function, it adds a new layer, then forgets about it.
I would suggest subclassing UITextField instead. That code could look like this:
class UnderlinedTextField: UITextField {
let underlineLayer = CALayer()
/// Size the underline layer and position it as a one point line under the text field.
func setupUnderlineLayer() {
var frame = self.bounds
frame.origin.y = frame.size.height - 1
frame.size.height = 1
underlineLayer.frame = frame
underlineLayer.backgroundColor = UIColor.blue.cgColor
}
// In `init?(coder:)` Add our underlineLayer as a sublayer of the view's main layer
required init?(coder: NSCoder) {
super.init(coder: coder)
self.layer.addSublayer(underlineLayer)
}
// in `init(frame:)` Add our underlineLayer as a sublayer of the view's main layer
override init(frame: CGRect) {
super.init(frame: frame)
self.layer.addSublayer(underlineLayer)
}
// Any time we are asked to update our subviews,
// adjust the size and placement of the underline layer too
override func layoutSubviews() {
super.layoutSubviews()
setupUnderlineLayer()
}
}
That creates a text field that looks like this:
(And note that if you rotate the simulator to landscape mode, the UnderlineTextField repositions the underline layer for the new text field bounds.)
Note that it might be easier to just add a UIView to your storyboard, pinned to the bottom of your text field and one pixel tall, using your desired underline color. (You'd set up the underline view using AutoLayout constraints, and give it a background color.) If you did that you wouldn't need any code at all.
Edit:
I created a Github project demonstrating both approaches. (link)
I also added a view-based underline to my example app. That looks like this:
We are currently working in an older codebase for our iOS application and are running into a weird bug where the UIScrollViews paging is not matching on the initialization but only once a user selects the button to change the view.
Expected Result:
The result we have:
Each ScrollView has three slides nested inside of them. We initialize the ScrollView like this:
override init(frame: CGRect) {
super.init(frame: frame)
self.commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.commonInit()
}
private func commonInit() {
Bundle.main.loadNibNamed("DIScrollView", owner: self, options: nil)
contentView.frame = self.bounds
addSubview(contentView)
contentView.autoresizingMask = [.flexibleHeight,.flexibleWidth]
contentView.layer.borderColor = UIColor.white.cgColor
contentView.layer.borderWidth = 2.0
scrollView.delegate = self
setUpScrollViewer()
}
You can see we call to set up the ScrollView and that is done like this:
public func setUpScrollViewer() {
let slides = self.createSlides()
let defaultIndex = 1
scrollView.Initialize(slides: slides, scrollToIndex: defaultIndex)
pageControl.numberOfPages = slides.count
pageControl.currentPage = defaultIndex
}
Now that all the content is available for each slide, we want to handle the content and we do so with a ScrollView extension:
extension UIScrollView {
//this function adds slides to the scrollview and constraints to the subviews (slides)
//to ensure the subviews are properly sized
func Initialize(slides:[UIView], scrollToIndex:Int) {
//Take second slide to base size from
let frameWidth = slides[1].frame.size.width
self.contentSize = CGSize(width: frameWidth * CGFloat(slides.count), height: 1)
for i in 0 ..< slides.count {
//turn off auto contstraints. We will be setting our own
slides[i].translatesAutoresizingMaskIntoConstraints = false
self.addSubview(slides[i])
//pin the slide to the scrollviewers edges
if i == slides.startIndex {
slides[i].leadingAnchor.constraint(equalTo: self.leadingAnchor).isActive = true
} else { //pin each subsequent slides leading edge to the previous slides trailing anchor
slides[i].leadingAnchor.constraint(equalTo: slides[i - 1].trailingAnchor).isActive = true
}
slides[i].topAnchor.constraint(equalTo: self.topAnchor).isActive = true
slides[i].widthAnchor.constraint(equalTo: self.widthAnchor).isActive = true
slides[i].heightAnchor.constraint(equalTo: self.heightAnchor).isActive = true
}
//the last slides trailing needs to be pinned to the scrollviewers trailing.
slides.last?.trailingAnchor.constraint(equalTo: self.trailingAnchor).isActive = true
self.scrollRectToVisible(CGRect(x: frameWidth * CGFloat(scrollToIndex), y: 0, width: frameWidth, height: 1), animated: false)
}
}
I have tried manually setting contentOffset and nothing seems to be adjusting on the initialization. If the user selects the button it hides and then unhides it to display it properly with no logic adjusting this. Giving me the impression this issue is on the init.
Summary:
When the main view loads, the scrollView is showing me the first slide in the index when i need to be focused on the second slide. However if the user hides and then unhides the scrollView it works as intended.
How do i get the UIScrollView to actually load and initialize updating the scrollView to show the second slide and not initialize on the first slide?
Try explicitely running the scrollRectToVisible in the main thread using
DispatchQueue.main.async {
}
My guess is that all this code runs before the views are positioned by the layout system, and the first slide’s frame is the default 0 x 0 size. When the app returns to this view auto layout has figured out the size of this slide, so the calculation works.
Tap into the layout cycle to scroll to the right place after the layout. Maybe override viewDidLayoutSubviews() to check if it’s in the initial layout and then set the scroll position.
Use constraints for your contentView instead setting frame and autoresizingMask.
Call view.layoutIfNeeded() in the viewController before scrollRectToVisible or setContentOffset(I prefer the last)
I'm trying to draw a PDFPage onto the context of a UIView subclass:
class PDFPageView: UIView {
private let page: PDFPage
init(frame: CGRect, page: PDFPage) {
self.page = page
super.init(frame: frame)
}
required init?(coder aDecoder: NSCoder) {
fatalError()
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
// This scales the page correctly (ignoring aspect ratios for now, though)
let bounds = page.bounds(for: .mediaBox)
context.scaleBy(x: rect.width / bounds.width, y: rect.height / bounds.height)
// This has no effect when I use it (?)
// page.transform(context, for: .mediaBox)
page.draw(with: .mediaBox, to: context)
}
}
It is drawing something, but it is mirrored (vertically) and I don't know how to correct this. I thought page.transform(context, for: .mediaBox) should do everything so that the context renders the page correctly. But it does nothing, strangely.
I tried to manually set the scale. It's working, but only for positive values. If I try to set a negative x-scale or y-scale (to compensate for the mirroring) I only get a black screen on my device.
I'm not sure what I should do. Also, it is only working for some pdf. I wanted to show you a screenshot so I created a small pdf file (the ones I use for testing are copyrighted, I think) and loaded it into the app. But I only get a black screen her as well.
I'd really appreciate any help with this.
Thank you
EDIT: Okay, I got it kind of working. After scaling the y-axis by a negative value, I need to translate the context using context.translateBy(x: 0, y: rect.height). Then it's rendered correctly.
But still, why is page.transform(context, for: .mediaBox) not doing this? And is vertically flipping the context valid for every pdf page?
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.
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