How to use MPVolume View correctly? - ios

-This was my Question: This is an audio player, I removed all the other lines for you to be easy in reading. The problem is in MPVolumeView. When user swipes all the way to maximum the button of the slider hovers over the connectivity button. When user swipes the button of the slider all the way to minimum the button of the slider doesn't move to the end.
-Dear DonMag, I am really thankful to you, It works! and HOW! I am adding screenshots. I believe your answer will be helpful to a lot of self tights.
import UIKit
import AVFoundation
import MediaPlayer
import AVKit
class AudioPlayerViewControllerQ1: UIViewController {
#IBOutlet var holder: UIView!
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if holder.subviews.count == 0 {
}
let volumeView = MPVolumeView(frame: CGRect(x: 20,
y: holder.frame.size.height - 80,
width: holder.frame.size.width-40,
height: 30))
holder.addSubview(volumeView)
}
private func setupView() {
setupConstraints()
}
private func setupConstraints() {
NSLayoutConstraint.activate([
holder.leadingAnchor.constraint(equalTo: view.leadingAnchor),
holder.trailingAnchor.constraint(equalTo: view.trailingAnchor),
holder.topAnchor.constraint(equalTo: view.topAnchor),
holder.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
UIApplication.shared.isIdleTimerDisabled = true
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
UIApplication.shared.isIdleTimerDisabled = false
}
}

After quick research and experimentation -- it appears MPVolumeView is rather buggy :(
When instantiated, if the current device volume is greater than 0, the thumb will be offset on the x-axis. The higher the volume, the larger the offset.
Also, it doesn't play well at all with auto-layout constraints.
We can get around this by subclassing MPVolumeView and "fixing" the slider rect:
class MyVolumeView: MPVolumeView {
override func volumeSliderRect(forBounds bounds: CGRect) -> CGRect {
// this will avoid the thumb x-offset issue
// while keeping the route button vertically aligned
return bounds.insetBy(dx: 12.0, dy: 0.0).offsetBy(dx: -12.0, dy: -5.0)
}
}
Then, to correct the problems with the vertical layout, we will want to offset the Y position when we set its frame.
Here's a quick example of one way to do that. I've embedded MyVolumeView in a "container" view, and used a property observer to update the frame whenever the container view's bounds changes:
class AudioPlayerViewControllerQ1: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// let's give the view a background color so we can easily see its frame
view.backgroundColor = .systemYellow
// assuming "holder" view has buttons and other controls
// along with the MyVolumeView near the bottom
let holder = UIView()
holder.backgroundColor = .darkGray
holder.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(holder)
// create a separate "container" view for the MyVolumeView
let volumeViewContainer = UIView()
// we'll make it red for now so we can see it
volumeViewContainer.backgroundColor = .red
volumeViewContainer.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(volumeViewContainer)
// respect safe-area
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// let's make the holder 20-points inset on leading/trailing
holder.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
holder.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
// holder height (for this example) is 240.0
holder.heightAnchor.constraint(equalToConstant: 240.0),
// let's put its bottom 60-points from the bottom (of the safe area)
holder.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -160.0),
// volume view container leading/trailing equal to holder
volumeViewContainer.leadingAnchor.constraint(equalTo: holder.leadingAnchor, constant: 0.0),
volumeViewContainer.trailingAnchor.constraint(equalTo: holder.trailingAnchor, constant: 0.0),
// volume view container bottom equal to holder bottom
volumeViewContainer.bottomAnchor.constraint(equalTo: holder.bottomAnchor, constant: 0.0),
// volume view container height equal to 30-points
volumeViewContainer.heightAnchor.constraint(equalToConstant: 30.0),
])
// now we'll add a MPVolumeView to the container
let v = MyVolumeView()
volumeViewContainer.addSubview(v)
// we'll use a property observer to update the MyVolumeView frame
// whenever the container bounds changes
volumeViewContainer.addObserver(self, forKeyPath: "bounds", context: nil)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "bounds" {
// make sure we're getting notified of the MyVolumeView container view
if let cv = object as? UIView,
let mpv = cv.subviews.first as? MyVolumeView {
// set MyVolumeView frame to container view's bounds
// and offset its y-position by 4-points (because of its buggy layout)
mpv.frame = cv.bounds.offsetBy(dx: 0.0, dy: 4.0)
}
}
}
}
It looks like this when running:
and we can drag the thumb all the way to the left:
and to the right (without overlapping the route button):
Edit
Here are a couple simplified examples...
Using CGRect frames instead of constraints (as requested by the OP):
class AudioPlayerViewControllerQ1: UIViewController {
let holder = UIView()
let myVolumeView = MyVolumeView()
override func viewDidLoad() {
super.viewDidLoad()
// let's give the view a background color so we can easily see its frame
view.backgroundColor = .systemYellow
// assuming "holder" view has buttons and other controls
// along with the MPVolumeView near the bottom
holder.backgroundColor = .darkGray
view.addSubview(holder)
// now we'll add a MPVolumeView to the container
holder.addSubview(myVolumeView)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// let's make sure this only executes if the holder view frame has not been set yet
if holder.frame.width != 320.0 {
// set holder view frame to 320 x 240
holder.frame = CGRect(x: 0, y: 0, width: 320.0, height: 240.0)
// center it in the view
holder.center = view.center
// set myVolumeView frame to same width as holder view
// 30-points height, at bottom of holder view
myVolumeView.frame = CGRect(x: 0.0, y: holder.frame.height - 30.0, width: holder.frame.width, height: 30.0)
}
}
}
and, this one using constraints:
class AudioPlayerViewControllerQ1: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// let's give the view a background color so we can easily see its frame
view.backgroundColor = .systemYellow
// assuming "holder" view has buttons and other controls
// along with the MPVolumeView near the bottom
let holder = UIView()
holder.backgroundColor = .darkGray
holder.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(holder)
// now we'll add a MPVolumeView to the container
let myVolumeView = MyVolumeView()
myVolumeView.translatesAutoresizingMaskIntoConstraints = false
holder.addSubview(myVolumeView)
// respect safe-area
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// set holder view frame to 320 x 240
holder.widthAnchor.constraint(equalToConstant: 320.0),
holder.heightAnchor.constraint(equalToConstant: 240.0),
// center it
holder.centerXAnchor.constraint(equalTo: g.centerXAnchor),
holder.centerYAnchor.constraint(equalTo: g.centerYAnchor),
// constrain myVolumeView leading/trailing/bottom equal to holder view
myVolumeView.leadingAnchor.constraint(equalTo: holder.leadingAnchor, constant: 0.0),
myVolumeView.trailingAnchor.constraint(equalTo: holder.trailingAnchor, constant: 0.0),
myVolumeView.bottomAnchor.constraint(equalTo: holder.bottomAnchor, constant: 0.0),
// myVolumeView height
myVolumeView.heightAnchor.constraint(equalToConstant: 30.0),
])
}
}

Related

UIKit rotate view 90 degrees while keeping the bounds to edge

Update:
I have since tried setting my layer anchor paint to (0,0) and translate it back to frame (0,0) after rotation using this tutorial.
This, however, still doesn't address the early wrapping issue. See below. Setting the content inset on the right side (bottom side) does not work.
textView.frame = CGRect(x: 0, y: 0, width: view.bounds.height, height: view.bounds.width)
print(textView.frame)
textView.setAnchorPoint(CGPoint(x: 0, y: 0))
textView.transform = CGAffineTransform(rotationAngle: (CGFloat)(Double.pi/2));
print(textView.frame)
textView.transform = textView.transform.translatedBy(x: 0, y: -(view.bounds.width))
print(textView.frame)
textView.contentInset = UIEdgeInsets(top: 0, left: height, bottom: 0, right: 0)
Original question:
I want to rotate the only UIView in subview clockwise by 90 degrees while keeping its bounds to edges of the screen, that is, below the Navigation Bar and above the Tab Bar and in between two sides.
Normally there are two ways to do this, either set translatesAutoresizingMaskIntoConstraints to true and set subview.frame = view.bounds
or set translatesAutoresizingMaskIntoConstraints to false and add four anchors constraints (top, bottom,leading, trailing) to view's four anchor constrains.
This is what it usually will do.
However, if I were to rotate the view while keeping its bound like before, like below, how would I do that?
Here's my current code to rotate a UITextView 90 degrees clockwise. I don't have a tab bar in this case but it shouldn't matter. Since before the textview grows towards the bottom, after rotation the textview should grow towards the left side. The problem is, it's not bound to the edge I showed, it is behind the nav bar.
var textView = UITextView()
view.addSubview(textView)
textView.translatesAutoresizingMaskIntoConstraints = true
textView.transform = CGAffineTransform(rotationAngle: (CGFloat)(Double.pi/2));
textView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: view.bounds.height)
Here it completely disappears if I rotate it after setting the frames
I also tried adding arbitrary value to the textView's y frame like so
textView.frame = CGRect(x: 0, y: 100, width: view.bounds.width, height: view.bounds.height)
but the result is that words get wrapped before they reach the bottom edge.
I also tried adding constrains by anchor and setting translateautoresizingmaskintoconstraints to false
constraints.append(contentsOf: [
textView.topAnchor.constraint(equalTo: view.topAnchor),
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
but the result is still a white screen.
Besides what I showed, I experimented with a lot of things, adding bit of value here and there, but they all are kind of a hack and doesn't really scale. For example, if the device gets rotated to landscape mode, the entire view gets screwed up again.
So, my question is, what is the correct, scalable way to do this? I want to have this rotated textview that is able to grow(scroll) towards the left and correctly resized on any device height and any orientation.
I know this could be related to anchor point. But since I want my view to actually bound to edges and not just display its content like an usual rotated UIImage. What are the steps to achieve that?
Thank you.
We need to embed the UITextView in a UIView "container" ... constraining it to that container ... and then rotate the container view.
Because the textView will continue to have a "normal" rotation relative to its superview, it will behave as desired.
Quick example:
class ViewController: UIViewController {
let textView = UITextView()
let containerView = UIView()
override func viewDidLoad() {
super.viewDidLoad()
textView.translatesAutoresizingMaskIntoConstraints = false
// we're going to explicitly set the container view's frame
containerView.translatesAutoresizingMaskIntoConstraints = true
containerView.addSubview(textView)
view.addSubview(containerView)
// we'll inset the textView by 8-points on all sides
// so we can see that it's inside the container view
// avoid auto-layout error/warning messages
let cTrailing = textView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8.0)
cTrailing.priority = .required - 1
let cBottom = textView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -8.0)
cBottom.priority = .required - 1
NSLayoutConstraint.activate([
textView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 8.0),
textView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8.0),
// activate trailing and bottom constraints
cTrailing, cBottom,
])
textView.font = .systemFont(ofSize: 32.0, weight: .regular)
textView.text = "The quick red fox jumps over the lazy brown dog, and then goes to the kitchen to get some dinner."
// so we can see the framing
textView.backgroundColor = .yellow
containerView.backgroundColor = .systemBlue
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// inset the container view frame by 40
// leaving some empty space around it
// so we can tap the view
containerView.frame = view.frame.insetBy(dx: 40.0, dy: 40.0)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// end editing if textView is being edited
view.endEditing(true)
// if container view is NOT rotated
if containerView.transform == .identity {
// rotate it
containerView.transform = CGAffineTransform(rotationAngle: (CGFloat)(Double.pi/2));
} else {
// set it back to NOT rotated
containerView.transform = .identity
}
}
}
Output:
tapping anywhere white (so, tapping the controller's view instead of the container or the textView itself) will toggle rotated/non-rotated:
Edit - responding to comment...
The reason we have to work with the frame is because of the way .transform works.
When we apply a .transform it changes the frame of the view, but not its bounds.
Take a look at this quick example:
class ExampleViewController: UIViewController {
let greenLabel = UILabel()
let yellowLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
greenLabel.translatesAutoresizingMaskIntoConstraints = false
yellowLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(greenLabel)
view.addSubview(yellowLabel)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
yellowLabel.widthAnchor.constraint(equalToConstant: 200.0),
yellowLabel.heightAnchor.constraint(equalToConstant: 80.0),
yellowLabel.centerXAnchor.constraint(equalTo: g.centerXAnchor),
yellowLabel.centerYAnchor.constraint(equalTo: g.centerYAnchor),
greenLabel.topAnchor.constraint(equalTo: yellowLabel.topAnchor),
greenLabel.leadingAnchor.constraint(equalTo: yellowLabel.leadingAnchor),
greenLabel.trailingAnchor.constraint(equalTo: yellowLabel.trailingAnchor),
greenLabel.bottomAnchor.constraint(equalTo: yellowLabel.bottomAnchor),
])
yellowLabel.text = "Yellow"
greenLabel.text = "Green"
yellowLabel.textAlignment = .center
greenLabel.textAlignment = .center
yellowLabel.backgroundColor = .yellow.withAlphaComponent(0.80)
greenLabel.backgroundColor = .green
// we'll give the green label a larger, red font
greenLabel.font = .systemFont(ofSize: 48.0, weight: .bold)
greenLabel.textColor = .systemRed
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// if yellow label is NOT rotated
if yellowLabel.transform == .identity {
// rotate it
yellowLabel.transform = CGAffineTransform(rotationAngle: (CGFloat)(Double.pi/2));
} else {
// set it back to NOT rotated
yellowLabel.transform = .identity
}
print("Green - frame: \(greenLabel.frame) bounds: \(greenLabel.bounds)")
print("Yellow - frame: \(yellowLabel.frame) bounds: \(yellowLabel.bounds)")
}
}
We've created two labels, with the yellow label on top of the green label, and the green label constrained top/leading/trailing/bottom to the yellow label.
Notice that when we apply a rotation transform to the yellow label, the green label does not change.
If you look at the debug console output, you'll see that the yellow label's frame changes, but its bounds stays the same:
// not rotated
Green - frame: (87.5, 303.5, 200.0, 80.0) bounds: (0.0, 0.0, 200.0, 80.0)
Yellow - frame: (87.5, 303.5, 200.0, 80.0) bounds: (0.0, 0.0, 200.0, 80.0)
// rotated
Green - frame: (87.5, 303.5, 200.0, 80.0) bounds: (0.0, 0.0, 200.0, 80.0)
Yellow - frame: (147.5, 243.5, 80.0, 200.0) bounds: (0.0, 0.0, 200.0, 80.0)
So... to make use of auto-layout / constraints, we want to create a custom UIView subclass, which has the "container" view and the text view. Something like this:
class RotatableTextView: UIView {
public var containerInset: CGFloat = 0.0 { didSet { setNeedsLayout() } }
public var textViewInset: CGFloat = 0.0 {
didSet {
tvConstraints[0].constant = textViewInset
tvConstraints[1].constant = textViewInset
tvConstraints[2].constant = -textViewInset
tvConstraints[3].constant = -textViewInset
}
}
public let textView = UITextView()
public let containerView = UIView()
private var tvConstraints: [NSLayoutConstraint] = []
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
private func commonInit() {
textView.translatesAutoresizingMaskIntoConstraints = false
// we're going to explicitly set the container view's frame
containerView.translatesAutoresizingMaskIntoConstraints = true
containerView.addSubview(textView)
addSubview(containerView)
// avoid auto-layout error/warning messages
var c: NSLayoutConstraint!
c = textView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: textViewInset)
tvConstraints.append(c)
c = textView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: textViewInset)
tvConstraints.append(c)
c = textView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -textViewInset)
c.priority = .required - 1
tvConstraints.append(c)
c = textView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -textViewInset)
c.priority = .required - 1
tvConstraints.append(c)
NSLayoutConstraint.activate(tvConstraints)
}
func toggleRotation() {
// if container view is NOT rotated
if containerView.transform == .identity {
// rotate it
containerView.transform = CGAffineTransform(rotationAngle: (CGFloat)(Double.pi/2));
} else {
// set it back to NOT rotated
containerView.transform = .identity
}
}
override func layoutSubviews() {
super.layoutSubviews()
var r = CGRect(origin: .zero, size: frame.size)
containerView.frame = r.insetBy(dx: containerInset, dy: containerInset)
}
}
Now, in our controller, we can use constraints on our custom view like we always do:
class ViewController: UIViewController {
let rotTextView = RotatableTextView()
override func viewDidLoad() {
super.viewDidLoad()
rotTextView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(rotTextView)
// we'll inset the custom view on all sides
// so we can tap on the "root" view to toggle the rotation
let inset: CGFloat = 20.0
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
rotTextView.topAnchor.constraint(equalTo: g.topAnchor, constant: inset),
rotTextView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: inset),
rotTextView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -inset),
rotTextView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -inset),
])
// let's use a 32-point font
rotTextView.textView.font = .systemFont(ofSize: 32.0, weight: .regular)
// give it some initial text
rotTextView.textView.text = "The quick red fox jumps over the lazy brown dog, and then goes to the kitchen to get some dinner."
// if we want to inset either the container or the text view
//rotTextView.containerInset = 8.0
//rotTextView.textViewInset = 4.0
// so we can see the framing if insets are > 0
// if both insets are 0, we won't see these, so they don't need to be set
//rotTextView.backgroundColor = .systemBlue
//rotTextView.containerView.backgroundColor = .systemYellow
// let's set the text view background color to light-cyan
// so we can see its frame
rotTextView.textView.backgroundColor = UIColor(red: 0.75, green: 1.0, blue: 1.0, alpha: 1.0)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// end editing if textView is being edited
view.endEditing(true)
rotTextView.toggleRotation()
}
}
Note that this is just Example Code, but it should get you on your way.

How to achieve a responsive auto scrolling when UITextView is placed in UIScrollView?

UITextView itself comes with default Scrolling Enabled behavior.
When a new line is created (ENTER pressed) in UITextView, scrolling will happen automatically.
If you notice carefully, there is top padding and bottom padding, which will move along the scroll direction. It is achieved using the following code.
import UIKit
class ViewController: UIViewController {
#IBOutlet weak var bodyTextView: UITextView!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
// We want to have "clipToPadding" for top and bottom.
// To understand what is "clipToPadding", please refer to
// https://stackoverflow.com/a/46710968/72437
bodyTextView.textContainerInset = UIEdgeInsets(top: 40, left: 0, bottom: 40, right: 0)
}
}
Now, within a single view controller, besides UITextView, there are other UI components like labels, stack views, ... within same controller
We want them to move along together during scrolling. Hence, here's our improvements.
Place UITextView inside a UIScrollView.
Disable Scrolling Enabled in UITextView.
Here's how our storyboard looks like
Here's the initial outcome
We can observe the following shortcomings.
Auto scrolling only happen, when we type the first character in new line. It doesn't happen immediately when we ENTER a new line.
Auto scrolling no longer take consideration into bottom padding. Auto scrolling only happen when bottom edge of screen is "touched". To understand this, please with case without UIScrollView.
Do you have any suggestions, how I can overcome these 2 shortcomings?
A sample project to demonstrate such shortcomings can be downloaded from https://github.com/yccheok/uitextview-inside-uiscrollview
Thanks.
This is a well-known (and long running) quirk with UITextView.
When a newline is entered, the text view does not update its content size (and thus its frame, when .isScrollEnabled = false) until another character is entered on the new line.
It seems that most people have just accepted it as Apple's default behavior.
You'd want to do thorough testing, but after some quick testing this appears to be reliable:
func textViewDidChange(_ textView: UITextView) {
// if the cursor is at the end of the text, and the last char is a newline
if let selectedRange = textView.selectedTextRange,
let txt = textView.text,
!txt.isEmpty,
txt.last == "\n" {
let cursorPosition = textView.offset(from: textView.beginningOfDocument, to: selectedRange.start)
if cursorPosition == txt.count {
// UITextView has a quirk when last char is a newline...
// its size is not updated until another char is entered
// so, this will force the textView to scroll up
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: {
self.textView.sizeToFit()
self.textView.layoutIfNeeded()
// might prefer setting animated: true
self.scrollView.scrollRectToVisible(self.textView.frame, animated: false)
})
}
}
}
Here's a complete example implementation:
class ViewController: UIViewController, UITextViewDelegate {
let textView: UITextView = {
let v = UITextView()
v.textColor = .black
v.backgroundColor = .green
v.font = .systemFont(ofSize: 17.0)
v.isScrollEnabled = false
return v
}()
let scrollView: UIScrollView = {
let v = UIScrollView()
v.backgroundColor = .red
v.alwaysBounceVertical = true
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
// create a vertical stack view
let stackView = UIStackView()
stackView.axis = .vertical
stackView.spacing = 8
// add a few labels to the stack view
let strs: [String] = [
"Three Labels in a Vertical Stack View",
"Just so we can see that there are UI elements in the scroll view in addition to the text view.",
"Stack View is, of course,\nusing auto-layout constraints."
]
strs.forEach { str in
let v = UILabel()
v.backgroundColor = .yellow
v.text = str
v.textAlignment = .center
v.numberOfLines = 0
stackView.addArrangedSubview(v)
}
// we're setting constraints
[scrollView, stackView, textView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
}
// add views to hierarchy
scrollView.addSubview(stackView)
scrollView.addSubview(textView)
view.addSubview(scrollView)
// respect safe area
let g = view.safeAreaLayoutGuide
// references to scroll view's Content and Frame Layout Guides
let cg = scrollView.contentLayoutGuide
let fg = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
// constrain scrollView to view (safe area)
scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
// constrain stackView Top / Leading / Trailing to Content Layout Guide
stackView.topAnchor.constraint(equalTo: cg.topAnchor),
stackView.leadingAnchor.constraint(equalTo: cg.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: cg.trailingAnchor),
// stackView width equals scrollView Frame Layout Guide width
stackView.widthAnchor.constraint(equalTo: fg.widthAnchor),
// constrain textView Top to stackView Bottom + 12
// Leading / Trailing to Content Layout Guide
textView.topAnchor.constraint(equalTo: stackView.bottomAnchor, constant: 12.0),
textView.leadingAnchor.constraint(equalTo: cg.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: cg.trailingAnchor),
// textView width equals scrollView Frame Layout Guide width
textView.widthAnchor.constraint(equalTo: fg.widthAnchor),
// constrain textView Bottom to Content Layout Guide
textView.bottomAnchor.constraint(equalTo: cg.bottomAnchor),
])
// some initial text
textView.text = "This is the textView"
// set the delegate
textView.delegate = self
// if we want
//textView.textContainerInset = UIEdgeInsets(top: 40, left: 0, bottom: 40, right: 0)
// a right-bar-button to end editing
let btn = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(done(_:)))
navigationItem.setRightBarButton(btn, animated: true)
// keyboard notifications
let notificationCenter = NotificationCenter.default
notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillHideNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
}
#objc func done(_ b: Any?) -> Void {
view.endEditing(true)
}
#objc func adjustForKeyboard(notification: Notification) {
guard let keyboardValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return }
let keyboardScreenEndFrame = keyboardValue.cgRectValue
let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame, from: view.window)
if notification.name == UIResponder.keyboardWillHideNotification {
scrollView.contentInset = .zero
} else {
scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: keyboardViewEndFrame.height - view.safeAreaInsets.bottom, right: 0)
}
scrollView.scrollIndicatorInsets = scrollView.contentInset
}
func textViewDidChange(_ textView: UITextView) {
// if the cursor is at the end of the text, and the last char is a newline
if let selectedRange = textView.selectedTextRange,
let txt = textView.text,
!txt.isEmpty,
txt.last == "\n" {
let cursorPosition = textView.offset(from: textView.beginningOfDocument, to: selectedRange.start)
if cursorPosition == txt.count {
// UITextView has a quirk when last char is a newline...
// its size is not updated until another char is entered
// so, this will force the textView to scroll up
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: {
self.textView.sizeToFit()
self.textView.layoutIfNeeded()
// might prefer setting animated: true
self.scrollView.scrollRectToVisible(self.textView.frame, animated: false)
})
}
}
}
}

Dynamically Sized View based on Child View Controller

I have a custom view controller used as a child view controller:
class ChildViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .green
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
calculatePreferredSize()
}
func calculatePreferredSize() {
let targetSize = CGSize(width: view.bounds.width,
height: UIView.layoutFittingCompressedSize.height)
preferredContentSize = view.systemLayoutSizeFitting(targetSize)
}
}
then in the main view controller, I have this code:
class ViewController: UIViewController {
var container : UIView!
var childVC : ChildViewController!
var containerHeightConstraint: NSLayoutConstraint!
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .purple
// setup container to hold child vc
container = UIView()
container.backgroundColor = .systemPink
container.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(container)
container.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
container.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20).isActive = true
container.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20).isActive = true
containerHeightConstraint = NSLayoutConstraint()
containerHeightConstraint = container.heightAnchor.constraint(equalToConstant: 0)
containerHeightConstraint.isActive = true
// setup child vc
childVC = ChildViewController()
addChild(childVC)
container.addSubview(childVC.view)
childVC.view.frame = container.bounds
childVC.didMove(toParent: self)
// add contents into the child vc
let newView = UIView()
childVC.view.addSubview(newView)
newView.backgroundColor = .systemBlue
newView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
newView.topAnchor.constraint(equalTo: newView.superview!.topAnchor),
newView.leadingAnchor.constraint(equalTo: newView.superview!.leadingAnchor),
newView.trailingAnchor.constraint(equalTo: newView.superview!.trailingAnchor),
newView.heightAnchor.constraint(equalToConstant: 123),
])
}
override func preferredContentSizeDidChange(forChildContentContainer container: UIContentContainer) {
super.preferredContentSizeDidChange(forChildContentContainer: container)
if (container as? ChildViewController) != nil {
containerHeightConstraint.constant = container.preferredContentSize.height
}
}
}
I am trying to dynamically size the container view in the main VC based on the child's calculated height. The preferredContentSizeDidChange method is being called but in my calculation of the child VC's height (using UIView.layoutFittingCompressedSize), I'm always getting back 0. Even though I've checked the frame of the view added onto that view and it has the correct frame height (in this example, 123). As shown in the output logging below:
(lldb) po view.subviews
▿ 1 element
- 0 : <UIView: 0x12251cd40; frame = (0 0; 350 123); layer = <CALayer: 0x6000007a0e60>>
(lldb) po UIView.layoutFittingCompressedSize
▿ (0.0, 0.0)
- width : 0.0
- height : 0.0
Below is a screenshot from the simulator.
Am I using the UIView.layoutFittingCompressedSize incorrectly? How do I calculate the height of the child view based on its contents?
Autolayout can't calculate the newView content height, because it is missing constraints in the Y axis to solve the equation.
newView has only these constraints defined: top, leading, trailing and height.
It is missing the bottom constraint:
newView.bottomAnchor.constraint(equalTo: newView.superview!.bottomAnchor).isActive = true
The full set of constraints would look like the following:
NSLayoutConstraint.activate([
newView.topAnchor.constraint(equalTo: newView.superview!.topAnchor),
newView.leadingAnchor.constraint(equalTo: newView.superview!.leadingAnchor),
newView.trailingAnchor.constraint(equalTo: newView.superview!.trailingAnchor),
newView.heightAnchor.constraint(equalToConstant: 123),
newView.bottomAnchor.constraint(equalTo: newView.superview!.bottomAnchor)
])
Afterwards when I place a breakpoint into preferredContentSizeDidChange, I can print the container.preferredContentSize.height, which is 123.0.
EDIT
To avoid constraint breakage, we also need to use autolayout for childVC.view. Right now it is using autosizing mask, which only flows from top-down and creates constraints with 1000 priority.
childVC.view.frame = container.bounds
needs to be replaced with
childVC.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
childVC.view.topAnchor.constraint(equalTo: container.topAnchor),
childVC.view.leadingAnchor.constraint(equalTo: container.leadingAnchor),
childVC.view.trailingAnchor.constraint(equalTo: container.trailingAnchor),
childVC.view.bottomAnchor.constraint(equalTo: container.bottomAnchor)
])
and the containerHeightConstraint needs to have a reduced priority for the 0 height constraint, otherwise the system will always find the constraints ambiguous - the child controller wants to be 123 points tall, but the container height constraint is still at 0 before we call the preferredContentSizeDidChange method.
containerHeightConstraint.priority = .defaultLow

Dynamically find the right zoom scale to fit portion of view

I have a grid view, it's like a chess board. The hierarchy is this :
UIScrollView
-- UIView
---- [UIViews]
Here's a screenshot.
Knowing that a tile has width and height of tileSide, how can I find a way to programmatically zoom in focusing on the area with the blue border? I need basically to find the right zoomScale.
What I'm doing is this :
let centralTilesTotalWidth = tileSide * 5
zoomScale = CGFloat(centralTilesTotalWidth) / CGFloat(actualGridWidth) + 1.0
where actualGridWidth is defined as tileSide multiplied by the number of columns. What I'm obtaining is to see almost seven tiles, not the five I want to see.
Keep also present that the contentView (the brown one) has a full screen frame, like the scroll view in which it's contained.
You can do this with zoom(to rect: CGRect, animated: Bool) (Apple docs).
Get the frames of the top-left and bottom-right tiles
convert then to contentView coordinates
union the two rects
call zoom(to:...)
Here is a complete example - all via code, no #IBOutlet or #IBAction connections - so just create a new view controller and assign its custom class to GridZoomViewController:
class GridZoomViewController: UIViewController, UIScrollViewDelegate {
let scrollView: UIScrollView = {
let v = UIScrollView()
return v
}()
let contentView: UIView = {
let v = UIView()
return v
}()
let gridStack: UIStackView = {
let v = UIStackView()
v.axis = .vertical
v.distribution = .fillEqually
return v
}()
var selectedTiles: [TileView] = [TileView]()
override func viewDidLoad() {
super.viewDidLoad()
[gridStack, contentView, scrollView].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
var bColor: Bool = false
// create a 9x7 grid of tile views, alternating cyan and yellow
for _ in 1...7 {
// horizontal stack view
let rowStack = UIStackView()
rowStack.translatesAutoresizingMaskIntoConstraints = false
rowStack.axis = .horizontal
rowStack.distribution = .fillEqually
for _ in 1...9 {
// create a tile view
let v = TileView()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = bColor ? .cyan : .yellow
v.origColor = v.backgroundColor!
bColor.toggle()
// add a tap gesture recognizer to each tile view
let g = UITapGestureRecognizer(target: self, action: #selector(self.tileTapped(_:)))
v.addGestureRecognizer(g)
// add it to the row stack view
rowStack.addArrangedSubview(v)
}
// add row stack view to grid stack view
gridStack.addArrangedSubview(rowStack)
}
// add subviews
contentView.addSubview(gridStack)
scrollView.addSubview(contentView)
view.addSubview(scrollView)
let padding: CGFloat = 20.0
// respect safe area
let g = view.safeAreaLayoutGuide
// for scroll view content constraints
let cg = scrollView.contentLayoutGuide
// let grid width shrink if 7:9 ratio is too tall for view
let wAnchor = gridStack.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 1.0)
wAnchor.priority = .defaultHigh
NSLayoutConstraint.activate([
// constrain scroll view to view (safe area), all 4 sides with "padding"
scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: padding),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: padding),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -padding),
scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -padding),
// constrain content view to scroll view contentLayoutGuide, all 4 sides
contentView.topAnchor.constraint(equalTo: cg.topAnchor, constant: 0.0),
contentView.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 0.0),
contentView.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: 0.0),
contentView.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: 0.0),
// content view width and height equal to scroll view width and height
contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: 0.0),
contentView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor, constant: 0.0),
// activate gridStack width anchor
wAnchor,
// gridStack height = gridStack width at 7:9 ration (7 rows, 9 columns)
gridStack.heightAnchor.constraint(equalTo: gridStack.widthAnchor, multiplier: 7.0 / 9.0),
// make sure gridStack height is less than or equal to content view height
gridStack.heightAnchor.constraint(lessThanOrEqualTo: contentView.heightAnchor),
// center gridStack in contentView
gridStack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor, constant: 0.0),
gridStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor, constant: 0.0),
])
// so we can see the frames
view.backgroundColor = .blue
scrollView.backgroundColor = .orange
contentView.backgroundColor = .brown
// delegate and min/max zoom scales
scrollView.delegate = self
scrollView.minimumZoomScale = 0.25
scrollView.maximumZoomScale = 5.0
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return contentView
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: nil, completion: {
_ in
if self.selectedTiles.count == 2 {
// re-zoom the content on size change (such as device rotation)
self.zoomToSelected()
}
})
}
#objc
func tileTapped(_ gesture: UITapGestureRecognizer) -> Void {
// make sure it was a Tile View that sent the tap gesture
guard let tile = gesture.view as? TileView else { return }
if selectedTiles.count == 2 {
// if we already have 2 selected tiles, reset everything
reset()
} else {
// add this tile to selectedTiles
selectedTiles.append(tile)
// if it's the first one, green background, if it's the second one, red background
tile.backgroundColor = selectedTiles.count == 1 ? UIColor(red: 0.0, green: 0.75, blue: 0.0, alpha: 1.0) : .red
// if it's the second one, zoom
if selectedTiles.count == 2 {
zoomToSelected()
}
}
}
func zoomToSelected() -> Void {
// get the stack views holding tile[0] and tile[1]
guard let sv1 = selectedTiles[0].superview,
let sv2 = selectedTiles[1].superview else {
fatalError("problem getting superviews! (this shouldn't happen)")
}
// convert tile view frames to content view coordinates
let r1 = sv1.convert(selectedTiles[0].frame, to: contentView)
let r2 = sv2.convert(selectedTiles[1].frame, to: contentView)
// union the two frames to get one larger rect
let targetRect = r1.union(r2)
// zoom to that rect
scrollView.zoom(to: targetRect, animated: true)
}
func reset() -> Void {
// reset the tile views to their original colors
selectedTiles.forEach {
$0.backgroundColor = $0.origColor
}
// clear the selected tiles array
selectedTiles.removeAll()
// zoom back to full grid
scrollView.zoom(to: scrollView.bounds, animated: true)
}
}
class TileView: UIView {
var origColor: UIColor = .white
}
It will look like this to start:
The first "tile" you tap will turn green:
When you tap a second tile, it will turn red and we'll zoom in to that rectangle:
Tapping a third time will reset to starting grid.

Extracting a childView and repositioning it inside of a new parentView

I’m trying to rip a view from a stackView that is embedded in a scrollView and then reposition said view in the same location but in another view at the same level in the view hierarchy as the scrollView.
The effect I’m trying to achieve is that I’m animating the removal of a view— where the view would be super imposed in another view, while the scrollView would scroll up and new view would be added to the stackView all while the view that was ripped fades out.
Unfortunately, achieving this effect remains elusive as the rippedView is position at (x: 0, y: 0). When I try force a new frame onto this view its tough because Im guessing the pixel perfect correct frame. Here’s a bit of the code from my viewController:
/*
I tried to make insertionView and imposeView have the same dimensions as the scrollView and
the stackView respectively as I thought if the rippedView’s original superView is the same
dimensions as it’s new superView, the rippedView would be positioned in the same place
without me needing to alter its frame.
*/
let insertionView = UIView(frame: scrollView.frame)
let imposeView = UIView(frame: stackView.frame)
rippedView.removeFromSuperview()
insertionView.addSubview(imposeView)
imposeView.addSubview(rippedView)
let newFrame = CGRect(x: 0, y: 450, width: rippedView.intrinsicContentSize.width, height:
rippedView.intrinsicContentSize.height)
rippedView.frame = newFrame
self.view.addSubview(insertionView)
Before removing rippedView, get it's actual frame:
let newFrame = self.view.convert(rippedView.bounds, from: rippedView)
The issue you are hitting is likely due to the stackView's arranged subviews having .translatesAutoresizingMaskIntoConstraints set to false. I believe this happens automatically when you add a view to a stackView, unless you specify otherwise.
A stackView's arranged subviews have coordinates relative to the stackView itself. So the first view will be at 0,0. Since you are adding a "container" view with the same frame as the stackView, you can use the same coordinate space... but you'll need to enable .translatesAutoresizingMaskIntoConstraints.
Try it like this:
#objc func btnTapped(_ sender: Any?) -> Void {
// get a reference to the 3rd arranged subview in the stack view
let rippedView = stackView.arrangedSubviews[2]
// local var holding the rippedView frame (as set by the stackView)
// get it before moving view from stackView
let r = rippedView.frame
// instantiate views
let insertionView = UIView(frame: scrollView.frame)
let imposeView = UIView(frame: stackView.frame)
// add imposeView to insertionView
insertionView.addSubview(imposeView)
// add insertionView to self.view
self.view.addSubview(insertionView)
// move rippedView from stackView to imposeView
imposeView.addSubview(rippedView)
// just to make it easy to see...
rippedView.backgroundColor = .green
// set to TRUE
rippedView.translatesAutoresizingMaskIntoConstraints = true
// set the frame
rippedView.frame = r
}
Here's a full class example that you can run directly (just assign it to a view controller):
class RipViewViewController: UIViewController {
let aButton: UIButton = {
let v = UIButton()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .red
v.setTitle("Testing", for: .normal)
return v
}()
let scrollView: UIScrollView = {
let v = UIScrollView()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .systemBlue
return v
}()
let stackView: UIStackView = {
let v = UIStackView()
v.translatesAutoresizingMaskIntoConstraints = false
v.axis = .vertical
v.spacing = 8
return v
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(aButton)
view.addSubview(scrollView)
scrollView.addSubview(stackView)
let g = view.safeAreaLayoutGuide
let sg = scrollView.contentLayoutGuide
NSLayoutConstraint.activate([
aButton.topAnchor.constraint(equalTo: g.topAnchor, constant: 16.0),
aButton.centerXAnchor.constraint(equalTo: g.centerXAnchor, constant: 0.0),
scrollView.topAnchor.constraint(equalTo: aButton.bottomAnchor, constant: 40.0),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -40.0),
stackView.topAnchor.constraint(equalTo: sg.topAnchor, constant: 40.0),
stackView.leadingAnchor.constraint(equalTo: sg.leadingAnchor, constant: 20.0),
stackView.trailingAnchor.constraint(equalTo: sg.trailingAnchor, constant: 20.0),
stackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -40.0),
stackView.bottomAnchor.constraint(equalTo: sg.bottomAnchor, constant: 20.0),
])
for i in 1...5 {
let l = UILabel()
l.backgroundColor = .cyan
l.textAlignment = .center
l.text = "Label \(i)"
stackView.addArrangedSubview(l)
}
aButton.addTarget(self, action: #selector(btnTapped(_:)), for: .touchUpInside)
}
#objc func btnTapped(_ sender: Any?) -> Void {
// get a reference to the 3rd arranged subview in the stack view
let rippedView = stackView.arrangedSubviews[2]
// local var holding the rippedView frame (as set by the stackView)
// get it before moving view from stackView
let r = rippedView.frame
// instantiate views
let insertionView = UIView(frame: scrollView.frame)
let imposeView = UIView(frame: stackView.frame)
// add imposeView to insertionView
insertionView.addSubview(imposeView)
// add insertionView to self.view
self.view.addSubview(insertionView)
// move rippedView from stackView to imposeView
imposeView.addSubview(rippedView)
// just to make it easy to see...
rippedView.backgroundColor = .green
// set to TRUE
rippedView.translatesAutoresizingMaskIntoConstraints = true
// set the frame
rippedView.frame = r
}
}

Resources