Button anchored to InputAccessoryView working only in its frame - swift

Setup
I implemented an inputAcessoryView in a UIViewController via the following:
override var inputAccessoryView: UIView? {
get {
return bottomBarView
}
}
override var canBecomeFirstResponder: Bool {
return true
}
fileprivate lazy var bottomBarView: UIView = {
let view = UIView()
let separatorLineView = UIView()
view.frame = CGRect(x: 0, y: 0, width: self.view.frame.width, height: self.view.frame.height / 9)
view.backgroundColor = .white
/...
separatorLineView.backgroundColor = UIColor(r: 220, g: 220, b: 220, a: 1)
separatorLineView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(separatorLineView)
separatorLineView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
separatorLineView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
separatorLineView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
separatorLineView.heightAnchor.constraint(equalToConstant: 1).isActive = true
view.addSubview(self.photoButton)
self.photoButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
self.photoButton.centerYAnchor.constraint(equalTo: separatorLineView.centerYAnchor, constant: -12).isActive = true
self.photoButton.widthAnchor.constraint(equalToConstant: view.frame.width / 4.5).isActive = true
self.photoButton.heightAnchor.constraint(equalToConstant: view.frame.width / 4.5).isActive = true
return view
}()
photoButton implements an action on .touchUpInside.
The issue
The button is only clickable in the part included in inputAccessoryView's frame: if I click on it on the part that's outside, nothing happens.
Is there any quick way to make it work like so ?
I have tried moving the photoButton out of bottomBarView and adding it to the view instead however:
I need photoButton to be "anchored" to the inputAccessoryView, but it seems inputAccessoryView doesn't have anchor properties from within the view ? (I can only access their frame properties via bang unwrapping)
When I do so, photoButton is partly hidden behind inputAccessoryView and I can't bring it forward with view.bringSubview(ToFront:)
Thanks in advance.

The issue you have is that the inputAccessoryView (bottomBarView for you) has no height. All of the subviews you're adding are therefore outside of its bounds. If you were to set clipsToBounds = true, you would see that everything disappears.
The issue with this, as you're seeing, is that those subviews aren't getting touch events since they're outside the bounds of their superview. From Apple's docs:
If a touch location is outside of a view’s bounds, the hitTest:withEvent: method ignores that view and all of its subviews. As a result, when a view’s clipsToBounds property is NO, subviews outside of that view’s bounds are not returned even if they happen to contain the touch.
Understanding Event Handling, Responders, and the Responder Chain
The solution is to get the inputAccessoryView to be the correct height. self.view.frame.height / 9 is 0 in your example, since the frame has not been set by the time you're using it.
Instead, create a UIView subclass, with the following important parts:
class BottomBarView: UIView {
init(frame: CGRect) {
super.init(frame: frame)
autoresizingMask = .flexibleHeight
let someView = UIView()
addSubview(someView)
someView.translatesAutoresizingMaskIntoConstraints = false
someView.topAnchor.constraint(equalTo: topAnchor).isActive = true
someView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
someView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
someView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
// In this case, the top, bottom, and height constraints will
// determine the height of the inputAccessoryView
someView.heightAnchor.constraint(equalToConstant: 100).isActive = true
}
override var intrinsicContentSize: CGSize {
return CGSize(width: UIViewNoIntrinsicMetric, height: 0)
}
}
Try adding a button. You'll see that now it gets a touch event, since it's within the bounds of it's superview now.

I just had this problem myself in my own work over the past couple days.
You need to implement your own hitTest UIResponder method for your inputAcessoryView. Something that might look like this:
override func hitTest(_ point: CGPoint,
with event: UIEvent?) -> UIView? {
if self.photoButton.frame.contains(point) {
return self.photoButton
}
return super.hitTest(point, with: event)
}
More information can be seen in this related question.

Related

Updating constraints when device orientation change

I want to change a button height constraint according to device orientation. I am creating with height constraint. And then I am setting height constraint 60 for landscape mode, 40 for portrait mode. But when I change device orientation, height is not becoming bigger. Where is the problem. Here is my code
lazy var nextEpisodeButton: CustomPlayerButton = {
let nextEpisode = CustomPlayerButton(type: .nextEpisode, backgroundImage: nil)
nextEpisode.addTarget(self, action: #selector(nextEpisodeTapped), for: .touchUpInside)
nextEpisode.translatesAutoresizingMaskIntoConstraints = false
nextEpisode.adjustsImageWhenHighlighted = false
return nextEpisode
}()
func addNextEpisodeButton() {
view.addSubview(nextEpisodeButton)
NSLayoutConstraint.activate([
nextEpisodeButton.heightAnchor.constraint(equalToConstant: 40),
nextEpisodeButton.widthAnchor.constraint(equalToConstant: 120),
nextEpisodeButton.rightAnchor.constraint(equalTo: view.safeRightAnchor, constant: -20),
nextEpisodeButton.bottomAnchor.constraint(equalTo: view.safeBottomAnchor, constant: -60)
])
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.willTransition(to: size, with: coordinator)
if UIDevice.current.orientation.isLandscape {
nextEpisodeButton.heightAnchor.constraint(equalToConstant: 60).isActive = true
} else {
nextEpisodeButton.heightAnchor.constraint(equalToConstant: 40).isActive = true
}
nextEpisodeButton.layoutIfNeeded()
}
You should have a reference to nextEpisodeButton.heightAnchor.constraint(equalToConstant: 40) constraint somewhere in your ViewController and in willTransition callback just change its constant value. With your code you are creating and activating a new constraint every time you rotate rather than changing the existing one.
The constraints are persistent. When you are creating a second constraint for the same attribute (in your case the height of a view) and you are activating it, the 2 constraints are conflicting with each other.
So, you need to keep a reference for both of them and activate/deactivate them accordingly:
lazy var nextEpisodeButton: CustomPlayerButton = {
let nextEpisode = CustomPlayerButton(type: .nextEpisode, backgroundImage: nil)
nextEpisode.addTarget(self, action: #selector(nextEpisodeTapped), for: .touchUpInside)
nextEpisode.translatesAutoresizingMaskIntoConstraints = false
nextEpisode.adjustsImageWhenHighlighted = false
return nextEpisode
}()
private var buttonLandscapeHeightContraint: NSLayoutConstraint?
private var buttonPortraitHeightContraint: NSLayoutConstraint?
func addNextEpisodeButton() {
view.addSubview(nextEpisodeButton)
buttonLandscapeHeightContraint = nextEpisodeButton.heightAnchor.constraint(equalToConstant: 60)
buttonPortraitHeightContraint = nextEpisodeButton.heightAnchor.constraint(equalToConstant: 40)
NSLayoutConstraint.activate([
nextEpisodeButton.widthAnchor.constraint(equalToConstant: 120),
nextEpisodeButton.rightAnchor.constraint(equalTo: view.safeRightAnchor, constant: -20),
nextEpisodeButton.bottomAnchor.constraint(equalTo: view.safeBottomAnchor, constant: -60)
])
updateButtonHeightConstraint()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.willTransition(to: size, with: coordinator)
updateButtonHeightConstraint()
nextEpisodeButton.layoutIfNeeded()
}
private func updateButtonHeightConstraint() {
if UIDevice.current.orientation.isLandscape {
buttonPortraitHeightContraint?.isActive = false
buttonLandscapeHeightContraint?.isActive = true
} else {
buttonLandscapeHeightContraint?.isActive = false
buttonPortraitHeightContraint?.isActive = true
}
}
My way is to do two things:
Keep the constraints you wish to change in arrays to be activated and deactivated.
Detect device orientation through a means other than traits.
Let's start with the first. And you are off to a good start - the constraints are (a) in code and (b) using anchors. (If by chance you are using IB - Storyboards for others - you'll need to set the changing constraints as #IBOutlets.)
It looks like you are wanting this button to be in the right bottom, so let's make those constraints active:
nextEpisodeButton.rightAnchor.constraint(equalTo: view.safeRightAnchor, constant: -20).isActive = true
nextEpisodeButton.bottomAnchor.constraint(equalTo: view.safeBottomAnchor, constant: -60).isActive = true
This will pin things properly no matter what the orientation.
Now, let's say you want to change size. You need to put these into two arrays:
var portraitLayout = [NSLayoutConstraint]()
var landscapeLayout = [NSLayoutConstraint]()
portraitLayout.append(nextEpisodeButton.widthAnchor.constraint(equalToConstant: 120))
portraitLayout.append(nextEpisodeButton.heightAnchor.constraint(equalToConstant: 40))
landscapeLayout.append(nextEpisodeButton.widthAnchor.constraint(equalToConstant: 120))
landscapeLayout.append(nextEpisodeButton.heightAnchor.constraint(equalToConstant: 60))
This sets up a 40x120 button in portrait and a 60x120 button in landscape. This can (should?) be done in viewDidLoad. Now it's time to activate/deactivate....
Only one array should be active, and you'll need to do one at the time the view is initialized. I'll get to that, but first, let me show two lines of code that is necessary:
NSLayoutConstraint.deactivate(landscapeLayout)
NSLayoutConstraint.activate(portraitLayout)
You can try to add/delete constraints, but this is not only risky and not as easy to maintain, it's not even needed. Simply set all constraints the are constant as isActive = true, put the ones that change into arrays, and activate/deactive.
(If you want to animate such changes - I wouldn't for you - then do this and add UIView.animate(withDuration:) at the end.)
Now, the rough piece, detecting the orientation.
Apple decided to add trait collections a few years ago. They work well (and this year I finally get why they did it the way they did). But they have one serious issue - iPads in full screen mode always have a normal size. (I'm writing an iPad only app this year and in split screen mode it may be compact.)
Your question stressed orientation, so I'd recommend not use trait collection changes. Instead, use viewWillLayoutSubviews. For me, this seems to be more reliable - it's the earliest in the view controller lifecycle that I've found. You'll need to do two things... set the initial orientation and detect changes.
Here's my setup. In a UIView extension:
public func orientationHasChanged(_ isInPortrait:inout Bool) -> Bool {
if self.frame.width > self.frame.height {
if isInPortrait {
isInPortrait = false
return true
}
} else {
if !isInPortrait {
isInPortrait = true
return true
}
}
return false
}
public func setOrientation(_ p:[NSLayoutConstraint], _ l:[NSLayoutConstraint]) {
NSLayoutConstraint.deactivate(l)
NSLayoutConstraint.deactivate(p)
if self.bounds.width > self.bounds.height {
NSLayoutConstraint.activate(l)
} else {
NSLayoutConstraint.activate(p)
}
}
p and l are portrait and landscape respectively. All I do is simply check the bounds and active/deactive appropriately.
override func viewWillLayoutSubviews() {
super.viewDidLayoutSubviews()
if initialOrientation {
initialOrientation = false
if view.frame.width > view.frame.height {
isInPortrait = false
} else {
isInPortrait = true
}
view.setOrientation(p, l)
} else {
if view.orientationHasChanged(&isInPortrait) {
view.setOrientation(p, l)
}
}
}
This is likely overkill for your needs. I'm basically tracking two things (initial and current orientation) for changes and calling things when needed - viewWillLayoutSubviews can be called more than once during a load(?) and for other reasons than an orientation change.
Conclusion:
You are close, but you have a few changes. First, set your constant constraints to isActive = true and activate/deactivate the remaining ones as arrays. Second, unless your app is iPhone only (and even then it will still be available for iPad) do not use trait collections, but instead use the view controller lifecycle and the screen bounds.

How to resize a NSTextView automatically as per its content?

I am making an app where a user can click anywhere on the window and a NSTextView is added programmatically at the mouse location. I have got it working with the below code but I want this NSTextView to horizontally expand until it reaches the edge of the screen and then grow vertically. It currently has a fixed width and when I add more characters, the text view grows vertically (as expected) but I also want it to grow horizontally. How can I achieve this?
I have tried setting isHorizontallyResizable and isVerticallyResizable to true but this doesn't work. After researching for a while, I came across this https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/TextStorageLayer/Tasks/TrackingSize.html but this didn't work for me either.
Code in my ViewController to add the NSTextView to its view:
private func addText(at point: NSPoint) {
let textView = MyTextView(frame: NSRect(origin: point, size: CGSize(width: 150.0, height: 40.0)))
view.addSubview(textView)
}
And, MyTextView class looks like below:
class MyTextView: NSTextView {
override func viewWillDraw() {
isHorizontallyResizable = true
isVerticallyResizable = true
isRichText = false
}
}
I have also seen this answer https://stackoverflow.com/a/54228147/1385441 but I am not fully sure how to implement it. I have added this code snippet in MyTextView and used it like:
override func didChangeText() {
frame.size = contentSize
}
However, I think I am using it incorrectly. Ergo, any help would be much appreciated.
I'm a bit puzzled, because you're adding NSTextView to a NSView which is part of the NSViewController and then you're talking about the screen width. Is this part of your Presentify - Screen Annotation application? If yes, you have a full screen overlay window and you can get the size from it (or from the view controller's view).
view.bounds.size // view controller's view size
view.window?.frame.size // window size
If not and you really need to know the screen size, check the NSWindow & NSScreen.
view.window?.screen?.frame.size // screen size
Growing NSTextView
There's no any window/view controller's view resizing behavior specified.
import Cocoa
class BorderedTextView: NSTextView {
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
let path = NSBezierPath(rect: bounds)
NSColor.red.setStroke()
path.stroke()
}
}
class ViewController: NSViewController {
override func mouseUp(with event: NSEvent) {
// Convert point to the view coordinates
let point = view.convert(event.locationInWindow, from: nil)
// Initial size
let size = CGSize(width: 100, height: 25)
// Maximum text view width
let maxWidth = view.bounds.size.width - point.x // <----
let textView = BorderedTextView(frame: NSRect(origin: point, size: size))
textView.insertionPointColor = .orange
textView.drawsBackground = false
textView.textColor = .white
textView.isRichText = false
textView.allowsUndo = false
textView.font = NSFont.systemFont(ofSize: 20.0)
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = true
textView.textContainer?.widthTracksTextView = false
textView.textContainer?.heightTracksTextView = false
textView.textContainer?.size.width = maxWidth // <----
textView.maxSize = NSSize(width: maxWidth, height: 10000) // <----
view.addSubview(textView)
view.window?.makeFirstResponder(textView)
}
}
I finally got it to work (except for one minor thing). The link from Apple was the key here but they haven't described the code completely, unfortunately.
The below code work for me:
class MyTextView: NSTextView {
override func viewWillDraw() {
// for making the text view expand horizontally
textContainer?.heightTracksTextView = false
textContainer?.widthTracksTextView = false
textContainer?.size.width = 10000.0
maxSize = NSSize(width: 10000.0, height: 10000.0)
isHorizontallyResizable = true
isVerticallyResizable = true
isRichText = false
}
}
That one minor thing which I haven't been able to figure out yet is to limit expanding horizontally until the edge of the screen is reached. Right now it keeps on expanding even beyond the screen width and, in turn, the text is hidden after the screen width.
I think if I can somehow get the screen window width then I can replace 10000.0 with the screen width (minus the distance of text view from left edge) and I can limit the horizontal expansion until the edge of the screen. Having said that, keeping it 10000.0 won't impact performance as described in the Apple docs.

Why is my UIView not displaying correctly when updating autolayout constraints?

I am trying to animate a simple autolayout constraint change, but when I call it the first time it animates but instead of just stretching and changing the height, it moves the whole view up, if I call it again it then fixes itself.
Here is how I set up the constraints:
hiddenView.addSubview(topView)
topView.translatesAutoresizingMaskIntoConstraints = false
topView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
topView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
topView.bottomAnchor.constraint(equalTo: hiddenView.bottomAnchor).isActive = true
topViewDefaultTopAnchorConstraints.append(topView.topAnchor.constraint(equalTo: hiddenView.centerYAnchor))
topViewSelectedTopAnchorConstraints.append(topView.topAnchor.constraint(equalTo: hiddenView.topAnchor))
NSLayoutConstraint.activate(topViewDefaultTopAnchorConstraints)
And here is how I am updating them:
func showTopView() {
NSLayoutConstraint.deactivate(topViewDefaultTopAnchorConstraints)
NSLayoutConstraint.activate(topViewSelectedTopAnchorConstraints)
UIView.animate(withDuration: 0.25) {
self.view.layoutIfNeeded()
}
}
Update:
Here is a gif of what happens when calling showTopView, calling it again fixes the bottom constraint:
It should just animate up like in the second image, not bringing the whole view up, as the bottomAnchor does not change, how can I fix this?
Update: I realised that I am rounding the corners of topView and bottomView, if I don't round the top corners then it works correctly, so it has something to do with this.
override func viewDidLayoutSubviews() {
topView.roundCorners(corners: [.topLeft, .topRight], radius: 100*ScreenDimensions.ASPECT_RATIO_RESPECT_OF_XMAX)
bottomView.roundCorners(corners: [.topLeft, .topRight], radius: 100*ScreenDimensions.ASPECT_RATIO_RESPECT_OF_XMAX)
topConstraint = topView.topAnchor.constraint(equalTo: hiddenView.topAnchor, constant: hiddenView.frame.height/2)
topConstraint.isActive = true
}
Seems the hiddenView's bottomAnchor is pulled up along with the topView's bottom here. Make sure the hiddenView's bottomAnchor is constrained properly. And better way to do this would be to provide a heightAnchor and increase the heightConstraint constant value to animate the view to increased height.
I think I know what your issue is. You might need to play with the constant rather than having two different constraint defined that you activate/deactivate.
First, define a constraint a the top of your ViewController like so:
var topConstraint: NSLayoutConstraint!
Then, initialize it as shown below:
hiddenView.addSubview(topView)
topView.translatesAutoresizingMaskIntoConstraints = false
topView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
topView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
topView.bottomAnchor.constraint(equalTo: hiddenView.bottomAnchor).isActive = true
topConstraint = topView.topAnchor.constraint(equalTo: hiddenView.topAnchor, constant: hiddenView.frame.height/2)
topConstraint.isActive = true
Then inside showTopView, you only need to update the constant and that should create the animation you are looking for:
func showTopView() {
topConstraint.isActive = false
topConstraint.constant = 0
topConstraint.isActive = true
UIView.animate(withDuration: 0.25) {
self.view.layoutIfNeeded()
}
}
Similarly if you want to put the view back to normal, you'd implement it as follows:
func hideTopView() {
topConstraint.isActive = false
topConstraint.constant = hiddenView.frame.height/2
topConstraint.isActive = true
UIView.animate(withDuration: 0.25) {
self.view.layoutIfNeeded()
}
}
You may have better luck with this approach:
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let r = 100*ScreenDimensions.ASPECT_RATIO_RESPECT_OF_XMAX
topView.layer.cornerRadius = r
topView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
bottomView.layer.cornerRadius = r
bottomView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
}

Propagation of UITouch events

I have views arranged as shown in attached figure.
MAINVIEW is an added subview to UISCROLLVIEW
class NotewallController:UIViewController, UIScrollViewDelegate {
var bgImage:UIImageView?
var bgScrollView:UIScrollView?
var masterView:UIView?
override func viewDidLoad() {
self.bgScrollView = UIScrollView(frame: self.view.bounds)
self.bgScrollView!.contentSize = CGSizeMake(1000, 1000)
self.bgScrollView!.delegate = self
self.bgScrollView!.minimumZoomScale = 1.0
self.bgScrollView!.maximumZoomScale = 4.0
self.view.addSubview(self.bgScrollView!)
masterView = UIView(frame: CGRectMake(0,0,1000,1000))
masterView!.backgroundColor = UIColor.clearColor()
masterView!.userInteractionEnabled = true
bgScrollView!.addSubview(masterView!)
let note = WALLVIEW(frame: CGRectMake(20, 20, 30, 30))
self.masterView!.addSubview(note)
}
WALLVIEW is a subclass of UIVIEW
class WallView:UIView {
override init(frame: CGRect) {
super.init(frame: frame)
let bgImageView = UIImageView(frame: frame)
bgImageView.image = UIImage(named: "stickynote.png")
bgImageView.userInteractionEnabled = true
self.addSubview(bgImageView)
let tap = UITapGestureRecognizer(target: self, action: "WallNoteTapped:")
bgImageView.addGestureRecognizer(tap)
}
My issue is in WALLVIEW. As you can notice, I have a TAP GESTURE in WALLVIEW. When the program is executed, a tap of WALLVIEW object doesn't call the "WallNoteTapped:" method. Suspecting the touch events are not passed to WALLVIEW object "note".
I am not able to understand why "WallNoteTapped:" method is not triggered.
Thanks
EDIT:
I made WALLVIEW subclass of UIImageView rather than UIView and it worked finally.
Adding an UITapGestureRecognizer cancel te touches in its subviews.
Try to set cancelsTouchesInView to false.

create scrollview programmatically in swift without outlet

I have tried enough but i don't understand what is wrong with code, it was working fine when I created an outlet, but then I found out that i don't need an outlet, so want to create it just programmatically.
var BestPractices = UIScrollView(frame: CGRectMake(0, 94, 768, 924))
BestPractices.hidden = true
I cannot access the properties of "BestPractices" in viewController , while same code works fine in playground
This is an answer from this question.
.
class ScrollingViewController : UIViewController {
// Create a scrollView property that we'll set as our view in -loadView
let scrollView = UIScrollView(frame: UIScreen.mainScreen().bounds)
override func loadView() {
// calling self.view later on will return a UIView!, but we can simply call
// self.scrollView to adjust properties of the scroll view:
self.view = self.scrollView
// setup the scroll view
self.scrollView.contentSize = CGSize(width:1234, height: 5678)
// etc...
}
func example() {
let sampleSubView = UIView()
self.view.addSubview(sampleSubView) // adds to the scroll view
// cannot do this:
// self.view.contentOffset = CGPoint(x: 10, y: 20)
// so instead we do this:
self.scrollView.contentOffset = CGPoint(x: 10, y: 20)
}
add this line to identify scroll bar at last of loadview().
scrollView.backgroundColor = UIColor.blue