I am having issues in my code with constraints. I'm seeing the following types of errors:
Will attempt to recover by breaking constraint <NSLayoutConstraint:0x600000ea6440 UIView:0x7fc684509760.height == UIScrollView:0x7fc685818000.height (active)>
Make a symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints to catch this in the debugger. The methods in the UIConstraintBasedLayoutDebugging category on UIView listed in <UIKitCore/UIView.h> may also be helpful.
I think the constraints are the problem.
Another problem that I have is with the buttons in buttonview. They are offset a little bit and sometimes when the numN is changing it doesn't fit well on the screen.
import UIKit
import Foundation
class PianoRollView: UIView {
var numN = 127
var numT = 4
override func draw(_ rect: CGRect) {
super.draw(rect)
// Draw the grid
let context = UIGraphicsGetCurrentContext()
context?.setStrokeColor(UIColor.black.cgColor)
context?.setLineWidth(1.0)
let noteHeight = rect.height / CGFloat(numN)
for i in 1..<numN {
let y = CGFloat(i) * noteHeight
context?.move(to: CGPoint(x: 0, y: y))
context?.addLine(to: CGPoint(x: rect.width, y: y))
}
let timeSlotWidth = rect.width / CGFloat(numT)
for i in 1..<numT {
let x = CGFloat(i) * timeSlotWidth
context?.move(to: CGPoint(x: x, y: 0))
context?.addLine(to: CGPoint(x: x, y: rect.height))
}
// Add line to right of grid
context?.move(to: CGPoint(x: rect.width, y: 0))
context?.addLine(to: CGPoint(x: rect.width, y: rect.height))
// Add line to left of grid
context?.move(to: CGPoint(x: 0, y: 0))
context?.addLine(to: CGPoint(x: 0, y: rect.height))
// Add line to top of grid
context?.move(to: CGPoint(x: 0, y: 0))
context?.addLine(to: CGPoint(x: rect.width, y: 0))
// Draw bottom line
context?.move(to: CGPoint(x: 0, y: rect.height))
context?.addLine(to: CGPoint(x: rect.width, y: rect.height))
context?.strokePath()
}
}
class ViewController: UIViewController, UIScrollViewDelegate {
let scrollView = UIScrollView()
let pianoRollView = PianoRollView()
// Create a container view to hold both the PianoRollView and the ButtonView
let containerView = UIView()
override func viewDidLoad() {
super.viewDidLoad()
// Add the UIScrollView to the view controller's view
view.addSubview(scrollView)
// Set the container view as the content view of the scroll view
scrollView.addSubview(containerView)
// Disable the autoresizing mask for both the UIScrollView and the PianoRollView
scrollView.translatesAutoresizingMaskIntoConstraints = false
pianoRollView.translatesAutoresizingMaskIntoConstraints = false
// Set the constraints for the UIScrollView to fill the view controller's view
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
scrollView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
// Initialize the piano roll view
pianoRollView.frame = CGRect(x: 0, y: 0, width: 2000, height: 2000)
pianoRollView.layer.zPosition = 1
containerView.frame = pianoRollView.frame
containerView.addSubview(pianoRollView)
// Set the constraints for the PianoRollView to fill the container view
pianoRollView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor).isActive = true
pianoRollView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor).isActive = true
pianoRollView.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true
pianoRollView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true
pianoRollView.widthAnchor.constraint(equalTo: containerView.widthAnchor).isActive = true
// Set the constraints for the container view to fill the scroll view
containerView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor).isActive = true
containerView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true
containerView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true
containerView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true
containerView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true
containerView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true
// Add a UIView under the PianoRollView
let buttonView = UIView()
buttonView.translatesAutoresizingMaskIntoConstraints = false
buttonView.frame = pianoRollView.frame
containerView.addSubview(buttonView)
// Set the constraints for the ButtonView to align with the PianoRollView
buttonView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor).isActive = true
buttonView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor).isActive = true
buttonView.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true
buttonView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true
buttonView.heightAnchor.constraint(equalTo: containerView.heightAnchor).isActive = true
// calculate size of notes
let noteHeight = Int(round(pianoRollView.frame.height / CGFloat(pianoRollView.numN)) )
// Add buttons to the buttonView with the same height as the space between the grids
for i in 0..<pianoRollView.numN {
let button = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
buttonView.addSubview(button)
button.leadingAnchor.constraint(equalTo: buttonView.leadingAnchor).isActive = true
button.trailingAnchor.constraint(equalTo: buttonView.trailingAnchor).isActive = true
button.topAnchor.constraint(equalTo: buttonView.topAnchor, constant: CGFloat(i) * CGFloat(noteHeight)).isActive = true
button.bottomAnchor.constraint(equalTo: buttonView.topAnchor, constant: CGFloat(i+1) * CGFloat(noteHeight)).isActive = true
button.setTitle("Button \(i)", for: .normal)
button.setTitleColor(.black, for: .normal)
button.backgroundColor = .red
}
// Set the content size of the scroll view to match the size of the piano roll view
scrollView.contentSize = containerView.bounds.size
// Enable scrolling in both directions
scrollView.isScrollEnabled = true
scrollView.showsVerticalScrollIndicator = true
scrollView.showsHorizontalScrollIndicator = true
// Set the background color of the piano roll view to white
pianoRollView.backgroundColor = UIColor.clear
// Set the delegate of the scroll view to self
scrollView.delegate = self
// Set the minimum zoom scale
let minZoomScale = max(view.bounds.width / pianoRollView.bounds.width, view.bounds.height / pianoRollView.bounds.height)
scrollView.minimumZoomScale = minZoomScale
// scrollView.minimumZoomScale = 1
scrollView.maximumZoomScale = 5
}
// Return the container view in the viewForZooming method
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return containerView
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
// Adjust the content offset so that the content stays centered when zooming
let horizontalInset = max(0, (scrollView.bounds.width - scrollView.contentSize.width) / 2)
let verticalInset = max(0, (scrollView.bounds.height - scrollView.contentSize.height) / 2)
scrollView.contentInset = UIEdgeInsets(top: verticalInset, left: horizontalInset, bottom: verticalInset, right: horizontalInset)
}
}
Part of the problem is that you're mixing explicit frame settings with constraints.
As a rule when working with UIScrollView
, we either set frames and .contentSize
OR we use constraints and let auto-layout do all the work for us.
It can be very helpful to organize your code into "related" tasks. For example:
With your project, scrollView
/ pianoRollView
/ containerView
are created as class properties, so the first thing in viewDidLoad()
will be:
// buttons will go under the PianoRollView
let buttonView = UIView()
next, add them to the view hierarchy:
// Add the UIScrollView to the view controller's view
view.addSubview(scrollView)
// Set the container view as the content view of the scroll view
scrollView.addSubview(containerView)
// add buttonView to containerView
containerView.addSubview(buttonView)
// add pianoRollView to containerView
containerView.addSubview(pianoRollView)
we want to use auto-layout, so follow that with:
// we will use auto-layout on all views
scrollView.translatesAutoresizingMaskIntoConstraints = false
containerView.translatesAutoresizingMaskIntoConstraints = false
buttonView.translatesAutoresizingMaskIntoConstraints = false
pianoRollView.translatesAutoresizingMaskIntoConstraints = false
Now we can set constraints on all those views, relative to each other...
// we (almost) always want to respect the safe area
let safeG = view.safeAreaLayoutGuide
// we want to constrain scrollView subviews to the scrollView's Content Layout Guide
let contentG = scrollView.contentLayoutGuide
NSLayoutConstraint.activate([
// Set the constraints for the UIScrollView to fill the view controller's view (safe area)
scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: safeG.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: safeG.bottomAnchor),
// constrain containerView to Content Layout Guide
// this will define the "scrollable area"
// so we won't be setting .contentSize anywhere
containerView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor),
containerView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor),
containerView.topAnchor.constraint(equalTo: contentG.topAnchor),
containerView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor),
// constrain all 4 sides of buttonView to containerView
buttonView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
buttonView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
buttonView.topAnchor.constraint(equalTo: containerView.topAnchor),
buttonView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
// constrain all 4 sides of pianoRollView to containerView
pianoRollView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
pianoRollView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
pianoRollView.topAnchor.constraint(equalTo: containerView.topAnchor),
pianoRollView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
// pianoRollView width
pianoRollView.widthAnchor.constraint(equalToConstant: 2000.0),
])
Note that we do NOT set a heightAnchor
on pianoRollView
. You'll see why...
You're also adding numN
buttons to buttonView
... that makes a little more sense to do after setting constraints on the above views.
Your code indicates you want pianoView (and thus, buttonView) to have a Height of 2000. And then you want to evenly fill that height with numN
buttons.
In the code you posted, you're calculating the buttons' height like this:
let noteHeight = Int(round(pianoRollView.frame.height / CGFloat(pianoRollView.numN)) )
So, Height is 2000 and numN is 127:
2000.0 / 127.0 == 15.748031496062993
You're using round()
, which is fine because it's a good idea to NOT use partial-points.
If we use round()
, we get:
round(2000.0 / 127.0) == 16.0
but, if we have 127 buttons each with a Height of 16...
16 * 127 == 2032
So we won't see the bottom two buttons!
We could try using floor()
... and we get:
floor(2000.0 / 127.0) == 15
but, if we have 127 buttons each with a Height of 15...
15 * 127 == 1905
So we have a 95-point "gap" at the bottom!
What you probably want to do is use floor()
and ceil()
, and then compare those values to see which one is closest to 2000.
This could be written in fewer lines, but for clarification:
// in this example, pianoRollView.numN is 127
let targetHeight: CGFloat = 2000.0
let floatNumN: CGFloat = CGFloat(pianoRollView.numN)
let noteH1: CGFloat = floor(targetHeight / floatNumN) // == 15.0
let noteH2: CGFloat = ceil(targetHeight / floatNumN) // == 16.0
let totalHeight1: CGFloat = noteH1 * floatNumN // == 1905
let totalHeight2: CGFloat = noteH2 * floatNumN // == 2032
let diff1: CGFloat = abs(targetHeight - totalHeight1) // == 95
let diff2: CGFloat = abs(targetHeight - totalHeight2) // == 32
// if diff1 is less than diff2, use noteH1 else noteH2
let noteHeight: CGFloat = diff1 < diff2 ? noteH1 : noteH2
noteHeight
now equals 16
and the totalHeight
equals 2032
... which is about 2000.
Instead of setting a height on buttonView
and then looping through and setting frames of the buttons, let's continue to take advantage of auto-layout.
We'll create a "vertical chain" of constraints:
buttonView
buttonView
that constraint chain now:
Another issue you have is that you are using different calculations for noteHeight
for your buttons:
let noteHeight = Int(round(pianoRollView.frame.height / CGFloat(pianoRollView.numN)) )
and for the horizontal lines in pianoRollView
:
let noteHeight = rect.height / CGFloat(numN)
So your buttons each had a height of 16-points
, but your lines were spaced at 15.748031496062993
.
A better option would be to make noteHeight
a property of pianoRollView
... and set it when you've calculated noteHeight
(the button height) in viewDidLoad()
. No need for another calculation.
Here's the full thing, with some additional modifications. Read through the comments so you understand what's going on:
class PianoRollView: UIView {
var noteHeight: CGFloat = 0
var numN = 127
var numT = 4
override func draw(_ rect: CGRect) {
super.draw(rect)
// Draw the grid
let context = UIGraphicsGetCurrentContext()
context?.setStrokeColor(UIColor.black.cgColor)
context?.setLineWidth(1.0)
// UIKit *may* tell us to draw only PART of the view
// (the rect), so use bounds instead of rect
// noteHeight will be set by the controller
// when it calculates the button heights
// let noteHeight = rect.height / CGFloat(numN)
for i in 1..<numN {
let y = CGFloat(i) * noteHeight
context?.move(to: CGPoint(x: 0, y: y))
context?.addLine(to: CGPoint(x: bounds.width, y: y))
}
let timeSlotWidth = bounds.width / CGFloat(numT)
for i in 1..<numT {
let x = CGFloat(i) * timeSlotWidth
context?.move(to: CGPoint(x: x, y: 0))
context?.addLine(to: CGPoint(x: x, y: bounds.height))
}
// Add line to right of grid
context?.move(to: CGPoint(x: bounds.width, y: 0))
context?.addLine(to: CGPoint(x: bounds.width, y: bounds.height))
// Add line to left of grid
context?.move(to: CGPoint(x: 0, y: 0))
context?.addLine(to: CGPoint(x: 0, y: bounds.height))
// Add line to top of grid
context?.move(to: CGPoint(x: 0, y: 0))
context?.addLine(to: CGPoint(x: bounds.width, y: 0))
// Draw bottom line
context?.move(to: CGPoint(x: 0, y: bounds.height))
context?.addLine(to: CGPoint(x: bounds.width, y: bounds.height))
context?.strokePath()
}
}
class PianoViewController: UIViewController, UIScrollViewDelegate {
let scrollView = UIScrollView()
let pianoRollView = PianoRollView()
// Create a container view to hold both the PianoRollView and the ButtonView
let containerView = UIView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
// buttons will go under the PianoRollView
let buttonView = UIView()
// Add the UIScrollView to the view controller's view
view.addSubview(scrollView)
// Set the container view as the content view of the scroll view
scrollView.addSubview(containerView)
// add buttonView to containerView
containerView.addSubview(buttonView)
// add pianoRollView to containerView
containerView.addSubview(pianoRollView)
// we will use auto-layout on all views
scrollView.translatesAutoresizingMaskIntoConstraints = false
containerView.translatesAutoresizingMaskIntoConstraints = false
buttonView.translatesAutoresizingMaskIntoConstraints = false
pianoRollView.translatesAutoresizingMaskIntoConstraints = false
// we (almost) always want to respect the safe area
let safeG = view.safeAreaLayoutGuide
// we want to constrain scrollView subviews to the scrollView's Content Layout Guide
let contentG = scrollView.contentLayoutGuide
NSLayoutConstraint.activate([
// Set the constraints for the UIScrollView to fill the view controller's view (safe area)
scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: safeG.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: safeG.bottomAnchor),
// constrain containerView to Content Layout Guide
// this will define the "scrollable area"
// so we won't be setting .contentSize anywhere
containerView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor),
containerView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor),
containerView.topAnchor.constraint(equalTo: contentG.topAnchor),
containerView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor),
// constrain all 4 sides of buttonView to containerView
buttonView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
buttonView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
buttonView.topAnchor.constraint(equalTo: containerView.topAnchor),
buttonView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
// constrain all 4 sides of pianoRollView to containerView
pianoRollView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
pianoRollView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
pianoRollView.topAnchor.constraint(equalTo: containerView.topAnchor),
pianoRollView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
// pianoRollView width
pianoRollView.widthAnchor.constraint(equalToConstant: 2000.0),
// we don't set height, because that will be controlled by the
// number of buttons (pianoRollView.numN) sized to get
// as close to 2000 as possible
//pianoRollView.heightAnchor.constraint(equalToConstant: 2000.0),
])
// calculate size of notes
// let's get close to 2000
// in this example, pianoRollView.numN is 127
let targetHeight: CGFloat = 2000.0
let floatNumN: CGFloat = CGFloat(pianoRollView.numN)
let noteH1: CGFloat = floor(targetHeight / floatNumN) // == 15.0
let noteH2: CGFloat = ceil(targetHeight / floatNumN) // == 16.0
let totalHeight1: CGFloat = noteH1 * floatNumN // == 1905
let totalHeight2: CGFloat = noteH2 * floatNumN // == 2032
let diff1: CGFloat = abs(targetHeight - totalHeight1) // == 95
let diff2: CGFloat = abs(targetHeight - totalHeight2) // == 32
// if diff1 is less than diff2, use noteH1 else noteH2
let noteHeight: CGFloat = diff1 < diff2 ? noteH1 : noteH2
// noteHeight now equals 16
// Add buttons to the buttonView
// we can constrain them vertically to each other
var previousButton: UIButton!
for i in 0..<pianoRollView.numN {
let button = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
buttonView.addSubview(button)
button.leadingAnchor.constraint(equalTo: buttonView.leadingAnchor).isActive = true
button.trailingAnchor.constraint(equalTo: buttonView.trailingAnchor).isActive = true
button.heightAnchor.constraint(equalToConstant: noteHeight).isActive = true
if previousButton == nil {
// constrain FIRST button to Top of buttonView
button.topAnchor.constraint(equalTo: buttonView.topAnchor).isActive = true
} else {
// constrain other buttons to Bottom of Previous Button
button.topAnchor.constraint(equalTo: previousButton.bottomAnchor).isActive = true
}
// update previousButton to the current button
previousButton = button
button.setTitle("Button \(i)", for: .normal)
button.setTitleColor(.black, for: .normal)
button.backgroundColor = .red
}
// constrain bottom of LAST button to bottom of buttonsView
previousButton.bottomAnchor.constraint(equalTo: buttonView.bottomAnchor).isActive = true
// so, our buttons are constrained with a "vertical chain" inside buttonView
// which determines the height of buttonView
// which also determines the height of pianoRollView
// which also determines the height of containerView
// which defines the .contentSize -- the "scrollable area"
// set the noteHeight in pianoRollView so it will match
// pianoRollView no longer needs to re-caluclate the "line spacing"
pianoRollView.noteHeight = noteHeight
// these all default to True, so not needed
// Enable scrolling in both directions
//scrollView.isScrollEnabled = true
//scrollView.showsVerticalScrollIndicator = true
//scrollView.showsHorizontalScrollIndicator = true
// Set the background color of the piano roll view to clear
pianoRollView.backgroundColor = UIColor.clear
// Set the delegate of the scroll view to self
scrollView.delegate = self
// Set the minimum zoom scale
// can't do this here... views have not been laid-out yet
// so do it in viewDidAppear
//let minZoomScale = max(view.bounds.width / pianoRollView.bounds.width, view.bounds.height / pianoRollView.bounds.height)
//scrollView.minimumZoomScale = minZoomScale
scrollView.maximumZoomScale = 5
// during development, so we can see the scrollView framing
scrollView.backgroundColor = .systemBlue
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// views have been laid-out, so now we can use their frames/bounds to calculate minZoomScale
let minZoomScale = min(scrollView.frame.width / pianoRollView.bounds.width, scrollView.frame.height / pianoRollView.bounds.height)
scrollView.minimumZoomScale = minZoomScale
}
// Return the container view in the viewForZooming method
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return containerView
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
// Adjust the content offset so that the content stays centered when zooming
let horizontalInset = max(0, (scrollView.frame.width - scrollView.contentSize.width) / 2)
let verticalInset = max(0, (scrollView.frame.height - scrollView.contentSize.height) / 2)
scrollView.contentInset = UIEdgeInsets(top: verticalInset, left: horizontalInset, bottom: verticalInset, right: horizontalInset)
}
}
If any of that is not clear - or even if you now understand it all - you would really benefit yourself by working through a bunch of Auto-Layout / UIScrollView
tutorials...
Edit
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// views have been laid-out, so now we can use their frames/bounds to calculate minZoomScale
// if we want min zoom to fit
// FULL pianoRollView
let minZoomScale = min(scrollView.frame.width / pianoRollView.bounds.width, scrollView.frame.height / pianoRollView.bounds.height)
// if we want min zoom to fit
// pianoRollView HEIGHT
//let minZoomScale = scrollView.frame.height / pianoRollView.bounds.height
// if we want min zoom to fit
// one "column" of pianoRollView
//let minZoomScale = scrollView.frame.width / (pianoRollView.bounds.width / CGFloat(pianoRollView.numT))
scrollView.minimumZoomScale = minZoomScale
}