SwiftUI center align a view except if another view "pushes" it up - swift

Here is a breakdown.
I have a zstack that contains 2 vstacks.
first vstack has a spacer and an image
second has a text and button.
ZStack {
VStack {
Spacer()
Image("some image")
}
VStack {
Text("press the button")
Button("ok") {
print("you pressed the button")
}
}
}
Now this setup would easily give me an image on the bottom of a zstack, and a centered title and button.
However if for example the device had a small screen or an ipad rotates to landscape. depending on the image size (which is dynamic). The title and button will overlap the image. instead of the button being "pushed" up.
In UIKit this is as simple as centering the button to superview with a high priority and having greaterThanOrEqualTo image.topAnchor with a required priority.
button would be centered in screen but if the top of the image was too big the center constraint would give priority to the image top anchor required constraint and push the button up.
I have looked into custom alignments and can easily get always above image or always center but am missing some insight in having it both depending on layout. Image size is dynamic so no hardcoded sizes.
What am i missing here? how would you solve this simple yet tricky task.

There might be an easier way using .alignmentGuide but I tried to practice on Layout for this answer.
I created a custom ImageAndButtonLayout that should do what you want: it takes two views assuming the first is the image and the second is the button (or anything else).
They are put into subviews just for clarity, you can also put them directly into ImageAndButtonLayout. For testing you can change the height of the image via slider.
The Layout always uses the available full height and pushes the first view (image) to the bottom - so you don't need an extra Spacer() with the image. The position of the second view (button) is calculated based on the height of the first view and the available height.
struct ContentView: View {
#State private var imageHeight = 200.0 // for testing
var body: some View {
VStack {
ImageAndButtonLayout {
imageView
buttonView
}
// changing "image" height for testing
Slider(value: $imageHeight, in: 50...1000)
.padding()
}
}
var imageView: some View {
Color.teal // Image placeholder
.frame(height: imageHeight)
}
var buttonView: some View {
VStack {
Text("press the button")
Button("ok") {
print("you pressed the button")
}
}
}
}
struct ImageAndButtonLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxsizes = subviews.map { $0.sizeThatFits(.infinity) }
var totalWidth = maxsizes.max {$0.width < $1.width}?.width ?? 0
totalWidth = min(totalWidth, proposal.width ?? .infinity )
let totalHeight = proposal.height ?? .infinity // always return maximum height
return CGSize(width: totalWidth, height: totalHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let heightImage = subviews.first?.sizeThatFits(.unspecified).height ?? 0
let heightButton = subviews.last?.sizeThatFits(.unspecified).height ?? 0
let maxHeightContent = bounds.height
// place image at bottom, growing upwards
let ptBottom = CGPoint(x: bounds.midX, y: bounds.maxY) // bottom of screen
if let first = subviews.first {
var totalWidth = first.sizeThatFits(.infinity).width
totalWidth = min(totalWidth, proposal.width ?? .infinity )
first.place(at: ptBottom, anchor: .bottom, proposal: .init(width: totalWidth, height: maxHeightContent))
}
// place button at center – or above image
var centerY = bounds.midY
if heightImage > maxHeightContent / 2 - heightButton {
centerY = maxHeightContent - heightImage
centerY = max ( heightButton * 2 , centerY ) // stop at top of screen
}
let ptCenter = CGPoint(x: bounds.midX, y: centerY)
if let last = subviews.last {
last.place(at: ptCenter, anchor: .center, proposal: .unspecified)
}
}
}

Related

Weird behavior when using SwiftUI View as accessory view for NSSavePanel

I'm trying to use a view written in SwiftUI as an accessory view of my NSSavePanel but I struggled to get it working properly.
Here's the implementation for my SwiftUI view:
struct ExportAccessoryView: View {
enum ExportFileType: String, Identifiable {
// ... enum declaration
}
#State var selectedExportFileType: ExportFileType = .png
#State var resolution = 256.0
#Binding var selectedFileTypeBinding: ExportFileType
#Binding var resolutionBinding: Double
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Picker(selection: $selectedExportFileType, label: Text("Format:")) {
Text("PDF").tag(ExportFileType.pdf)
// ... other items
}
.frame(width: 170)
.padding(.leading, 21)
if [ExportFileType.png, ExportFileType.jpeg, ExportFileType.tiff].contains(selectedExportFileType) {
HStack {
Slider(value: $resolution, in: 128...1024,
label: { Text("Resolution:") })
.frame(width: 200)
Text("\(Int(resolution))")
.frame(width: 40, alignment: .leading)
.padding(.leading, 5)
}
}
}
.padding(10)
.onChange(of: selectedExportFileType) { newValue in
self.selectedFileTypeBinding = newValue
}
.onChange(of: resolution) { newValue in
self.resolutionBinding = newValue
}
}
}
Here's how I implemented my save panel:
class DocumentWindow: NSWindowController {
var exportFileType: ExportAccessoryView.ExportFileType = .pdf
var resolution = 256.0
lazy var exportPanel: NSSavePanel = {
let savePanel = NSSavePanel()
savePanel.message = "Specify where and how you wish to export..."
savePanel.nameFieldLabel = "Export As:"
savePanel.canCreateDirectories = true
savePanel.isExtensionHidden = false
savePanel.showsTagField = true
let fileTypeBinding = Binding {
return self.exportFileType
} set: { newValue in
self.exportFileType = newValue
// update file extension
self.exportPanel.allowedContentTypes = [UTType(newValue.rawValue)!]
}
let resolutionBinding = Binding {
return self.resolution
} set: { newValue in
self.resolution = newValue
}
let accessoryView = ExportAccessoryView(selectedFileTypeBinding: fileTypeBinding,
resolutionBinding: resolutionBinding)
let exportAccessoryView = NSHostingController(rootView: accessoryView)
savePanel.accessoryView = exportAccessoryView.view
savePanel.allowedContentTypes = [UTType(self.exportFileType.rawValue)!]
return savePanel
}()
}
The save panel is presented by invoking beginSheetModal(for:completionHandler:).
It has no problem displaying but the accessory view is exhibiting some bizarre behavior: it seems to be doing its own thing at random (I sought for patterns but I failed to do so).
Sometimes it works properly, sometimes it becomes unclickable (but the function is still accessible via switch control using TAB). The alignment is always different from the last time I expanded/collapsed or opened/closed the panel: sometimes it's left aligned, sometimes it's centered (even if I have explicitly opted for .leading for alignment).
I have absolutely no idea what's going on. I don't know if this is an issue with SwiftUI+AppKit or is it that I'm doing it all wrong, which is highly likely since I'm a total newbie in SwiftUI. What should I do to get it working properly?
I remembered from back in the days when I was using XIB for implementing an accessory view: I used to embed the controls within an NSView and then set up constraints to make it work. So I applied the same idea here of embedding the NSHostingView's view within a custom NSView and after tweaking it for a bit, I made it work:
lazy var exportPanel: NSSavePanel = {
// ... setting up save panel
// instantiate SwiftUI view and its hosting controller
let accessoryView = ExportAccessoryView(selectedFileTypeBinding: fileTypeBinding,
resolutionBinding: resolutionBinding)
let exportAccessoryView = NSHostingController(rootView: accessoryView)
// embed the SwiftUI in a custom view
let customView = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 60))
customView.addSubview(exportAccessoryView.view)
// use my own constraints
exportAccessoryView.view.translatesAutoresizingMaskIntoConstraints = false
// top and bottom clipped to custom view
exportAccessoryView.view.topAnchor.constraint(equalTo: customView.topAnchor).isActive = true
exportAccessoryView.view.bottomAnchor.constraint(equalTo: customView.bottomAnchor).isActive = true
// leading and trailing spaces can stretch as far as they need to be, hence ≥0
exportAccessoryView.view.leadingAnchor.constraint(greaterThanOrEqualTo: customView.leadingAnchor).isActive = true
exportAccessoryView.view.trailingAnchor.constraint(greaterThanOrEqualTo: customView.trailingAnchor).isActive = true
// center the SwiftUI view horizontal within custom view
exportAccessoryView.view.centerXAnchor.constraint(equalTo: customView.centerXAnchor).isActive = true
// usually fixed width and height
// can be flexible when SwiftUI view is dynamic
exportAccessoryView.view.widthAnchor.constraint(equalToConstant: customView.frame.width).isActive = true
exportAccessoryView.view.heightAnchor.constraint(greaterThanOrEqualToConstant: customView.frame.height).isActive = true
savePanel.accessoryView = customView
// ... additional setup
return savePanel
}()
Now it works perfectly as expected. Don't know if this is the "proper way" to implement such integration.

SwiftUI What exactly happens when use .offset and .position Modifier simultaneously on a View, which decides the final location?

Here I have this question when I try to give a View an initial position, then user can use drag gesture to change the location of the View to anywhere. Although I already solved the issue by only using .position(x:y:) on a View, at the beginning I was thinking using .position(x:y:) to give initial position and .offset(offset:) to make the View move with gesture, simultaneously. Now, I really just want to know in more detail, what exactly happens when I use both of them the same time (the code below), so I can explain what happens in the View below.
What I cannot explain in the View below is that: when I simply drag gesture on the VStack box, it works as expected and the VStack moves with finger gesture, however, once the gesture ends and try to start a new drag gesture on the VStack, the VStack box goes back to the original position suddenly (like jumping to the original position when the code is loaded), then start moving with the gesture. Note that the gesture is moving as regular gesture, but the VStack already jumped to a different position so it starts moving from a different position. And this causes that the finger tip is no long on top of the VStack box, but off for some distance, although the VStack moves with the same trajectory as drag gesture does.
My question is: why the .position(x:y:) modifier seems only take effect at the very beginning of each new drag gesture detected, but during the drag gesture action on it seems .offset(offset:) dominates the main movement and the VStack stops at where it was dragged to. But once new drag gesture is on, the VStack jumps suddenly to the original position. I just could not wrap my head around how this behavior happens through timeline. Can somebody provide some insights?
Note that I already solved the issue to achieve what I need, right now it's just to understand what is exactly going on when .position(x:y:) and .offset(offset:) are used the same time, so please avoid some advice like. not use them simultaneously, thank you. The code bellow suppose to be runnable after copy and paste, if not pardon me for making mistake as I delete few lines to make it cleaner to reproduce the issue.
import SwiftUI
struct ContentView: View {
var body: some View {
ButtonsViewOffset()
}
}
struct ButtonsViewOffset: View {
let location: CGPoint = CGPoint(x: 50, y: 50)
#State private var offset = CGSize.zero
#State private var color = Color.purple
var dragGesture: some Gesture {
DragGesture()
.onChanged{ value in
self.offset = value.translation
print("offset onChange: \(offset)")
}
.onEnded{ _ in
if self.color == Color.purple{
self.color = Color.blue
}
else{
self.color = Color.purple
}
}
}
var body: some View {
VStack {
Text("Watch 3-1")
Text("x: \(self.location.x), y: \(self.location.y)")
}
.background(Color.gray)
.foregroundColor(self.color)
.offset(self.offset)
.position(x: self.location.x, y: self.location.y)
.gesture(dragGesture)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView()
}
}
}
Your issue has nothing to do with the use of position and offset. They actually both work simultaneously. Position sets the absolute position of the view, where as offset moves it relative to the absolute position. Therefore, you will notice that your view starts at position (50, 50) on the screen, and then you can drag it all around. Once you let go, it stops wherever it was. So far, so good. You then want to move it around again, and it pops back to the original position. The reason it does that is the way you set up location as a let constant. It needs to be state.
The problem stems from the fact that you are adding, without realizing it, the values of offset to position. When you finish your drag, offset retains the last values. However, when you start your next drag, those values start at (0,0) again, therefore the offset is reset to (0,0) and the view moves back to the original position. The key is that you need to use just the position or update the the offset in .onEnded. Don't use both. Here you have a set position, and are not saving the offset. How you handle it depends upon the purpose for which you are moving the view.
First, just use .position():
struct OffsetAndPositionView: View {
#State private var position = CGPoint(x: 50, y: 50)
#State private var color = Color.purple
var dragGesture: some Gesture {
DragGesture()
.onChanged{ value in
position = value.location
print("position onChange: \(position)")
}
.onEnded{ value in
if color == Color.purple{
color = Color.blue
}
else{
color = Color.purple
}
}
}
var body: some View {
Rectangle()
.fill(color)
.frame(width: 30, height: 30)
.position(position)
.gesture(dragGesture)
}
}
Second, just use .offset():
struct ButtonsViewOffset: View {
#State private var savedOffset = CGSize.zero
#State private var dragValue = CGSize.zero
#State private var color = Color.purple
var offset: CGSize {
savedOffset + dragValue
}
var dragGesture: some Gesture {
DragGesture()
.onChanged{ value in
dragValue = value.translation
print("dragValue onChange: \(dragValue)")
}
.onEnded{ value in
savedOffset = savedOffset + value.translation
dragValue = CGSize.zero
if color == Color.purple{
color = Color.blue
}
else{
color = Color.purple
}
}
}
var body: some View {
Rectangle()
.fill(color)
.frame(width: 30, height: 30)
.offset(offset)
.gesture(dragGesture)
}
}
// Convenience operator overload
func + (lhs: CGSize, rhs: CGSize) -> CGSize {
return CGSize(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
}

SwiftUI white space under the NavigationBar

I have a view with a NavigationBar with a large title and content inside. I've changed the content background color and the NavigationBar background color.
var body: some View {
NavigationView {
VStack {
//content
}.background(Color.green)
.edgesIgnoringSafeArea(.bottom) //to fill the white space at the bottom
.navigationBarTitle("Wallets")
}
}
extension UINavigationController {
override open func viewDidLoad() {
super.viewDidLoad()
let standartAppearance = UINavigationBarAppearance()
standartAppearance.backgroundColor = UIColor.green
navigationBar.standardAppearance = standartAppearance
navigationBar.scrollEdgeAppearance = standartAppearance
navigationBar.compactAppearance = standartAppearance
}
}
And now if I move to the next view using the NavigationLink, and this view has a title with .inline display mode (different NavigationBar size), in the moment of the transition I see the white space under the first Navigation bar. I can fill it if I make .edgesIgnoringSafeArea(.all) for my VStack, but in this case all my content jump under the NavigationBar. How can I color the space under the NavigationBar to custom color?
Looks like it's a bug. I didn't find the normal solution. Here is a workaround:
We should calculate the NavigationBar heigh and fill the space behind it.
var deviceHeight: CGFloat {
UIScreen.main.bounds.height
}
func calcPadding() -> CGFloat {
var padding: CGFloat = 0.0
switch self.deviceHeight {
case 568.0: padding = 94.0 + 6.0
case 667.0: padding = 96.0 + 10.0
case 736.0: padding = 96.0 + 10.0
case 812.0: padding = 106.0 + 10.0
case 896.0: padding = 106.0 + 10.0
default:
padding = 106.0
}
return padding
}
var body: some View {
NavigationView {
VStack {
VStack {
//content
}
.padding(.top, calcPadding())
.background(Color.myBackground)
}
.background(Color.myBackground)
.edgesIgnoringSafeArea(.all)
}
}

SwiftUI .rotationEffect() framing and offsetting

When applying .rotationEffect() to a Text, it rotates the text as expected, but its frame remains unchanged. This becomes an issue when stacking rotated views with non-rotated views, such as with a VStack of HStack, causing them to overlap.
I initially thought the rotationEffect would simply update the frame of the Text to be vertical, but this is not the case.
I've tried manually setting the frame size and (if needed, offsetting) the Text, which sort of works, but I don't like this solution because it requires some guessing and checking of where the Text will appear, how big to make the frame, etc.
Is this just how rotated text is done, or is there a more elegant solution to this?
struct TextAloneView: View {
var body: some View {
VStack {
Text("Horizontal text")
Text("Vertical text").rotationEffect(.degrees(-90))
}
}
}
Overlapping Text
You need to adjust the frame yourself in this case. That requires capturing what the frame is, and then applying the adjustment.
First, to capture the existing frame, create a preference, which is a system for passing data from child views to their parents:
private struct SizeKey: PreferenceKey {
static let defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
extension View {
func captureSize(in binding: Binding<CGSize>) -> some View {
overlay(GeometryReader { proxy in
Color.clear.preference(key: SizeKey.self, value: proxy.size)
})
.onPreferenceChange(SizeKey.self) { size in binding.wrappedValue = size }
}
}
This creates a new .captureSize(in: $binding) method on Views.
Using that, we can create a new kind of View that rotates its frame:
struct Rotated<Rotated: View>: View {
var view: Rotated
var angle: Angle
init(_ view: Rotated, angle: Angle = .degrees(-90)) {
self.view = view
self.angle = angle
}
#State private var size: CGSize = .zero
var body: some View {
// Rotate the frame, and compute the smallest integral frame that contains it
let newFrame = CGRect(origin: .zero, size: size)
.offsetBy(dx: -size.width/2, dy: -size.height/2)
.applying(.init(rotationAngle: CGFloat(angle.radians)))
.integral
return view
.fixedSize() // Don't change the view's ideal frame
.captureSize(in: $size) // Capture the size of the view's ideal frame
.rotationEffect(angle) // Rotate the view
.frame(width: newFrame.width, // And apply the new frame
height: newFrame.height)
}
}
And for convenience, an extension to apply it:
extension View {
func rotated(_ angle: Angle = .degrees(-90)) -> some View {
Rotated(self, angle: angle)
}
}
And now your code should work as you expect:
struct TextAloneView: View {
var body: some View {
VStack {
Text("Horizontal text")
Text("Vertical text").rotated()
}
}
}
RotationEffect takes a second argument which is the anchor point, if you omit it - the default is .center.
Try this instead:
.rotationEffect(.degrees(-90), anchor: .bottomTrailing)

Image for Navigation Bar with Large Title iOS 11

AppStore app has an icon with an image on the right side of the NabBar with Large Title:
Would really appreciate if anyone knows how to implement it or ideas on how to do it.
BTW: Setting an image for UIButton inside of UIBarButtonItem won't work. Tried already. The button sticks to the top of the screen:
After several hours of coding, I finally managed to make it work. I also decided to write a detailed tutorial: link. Follow it in case you prefer very detailed instructions.
Demo:
Complete project on GitHub: link.
Here are 5 steps to accomplish it:
Step 1: Create an image
private let imageView = UIImageView(image: UIImage(named: "image_name"))
Step 2: Add Constants
/// WARNING: Change these constants according to your project's design
private struct Const {
/// Image height/width for Large NavBar state
static let ImageSizeForLargeState: CGFloat = 40
/// Margin from right anchor of safe area to right anchor of Image
static let ImageRightMargin: CGFloat = 16
/// Margin from bottom anchor of NavBar to bottom anchor of Image for Large NavBar state
static let ImageBottomMarginForLargeState: CGFloat = 12
/// Margin from bottom anchor of NavBar to bottom anchor of Image for Small NavBar state
static let ImageBottomMarginForSmallState: CGFloat = 6
/// Image height/width for Small NavBar state
static let ImageSizeForSmallState: CGFloat = 32
/// Height of NavBar for Small state. Usually it's just 44
static let NavBarHeightSmallState: CGFloat = 44
/// Height of NavBar for Large state. Usually it's just 96.5 but if you have a custom font for the title, please make sure to edit this value since it changes the height for Large state of NavBar
static let NavBarHeightLargeState: CGFloat = 96.5
}
Step 3: setup UI:
private func setupUI() {
navigationController?.navigationBar.prefersLargeTitles = true
title = "Large Title"
// Initial setup for image for Large NavBar state since the the screen always has Large NavBar once it gets opened
guard let navigationBar = self.navigationController?.navigationBar else { return }
navigationBar.addSubview(imageView)
imageView.layer.cornerRadius = Const.ImageSizeForLargeState / 2
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
imageView.rightAnchor.constraint(equalTo: navigationBar.rightAnchor,
constant: -Const.ImageRightMargin),
imageView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor,
constant: -Const.ImageBottomMarginForLargeState),
imageView.heightAnchor.constraint(equalToConstant: Const.ImageSizeForLargeState),
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor)
])
}
Step 4: create image resizing method
private func moveAndResizeImage(for height: CGFloat) {
let coeff: CGFloat = {
let delta = height - Const.NavBarHeightSmallState
let heightDifferenceBetweenStates = (Const.NavBarHeightLargeState - Const.NavBarHeightSmallState)
return delta / heightDifferenceBetweenStates
}()
let factor = Const.ImageSizeForSmallState / Const.ImageSizeForLargeState
let scale: CGFloat = {
let sizeAddendumFactor = coeff * (1.0 - factor)
return min(1.0, sizeAddendumFactor + factor)
}()
// Value of difference between icons for large and small states
let sizeDiff = Const.ImageSizeForLargeState * (1.0 - factor) // 8.0
let yTranslation: CGFloat = {
/// This value = 14. It equals to difference of 12 and 6 (bottom margin for large and small states). Also it adds 8.0 (size difference when the image gets smaller size)
let maxYTranslation = Const.ImageBottomMarginForLargeState - Const.ImageBottomMarginForSmallState + sizeDiff
return max(0, min(maxYTranslation, (maxYTranslation - coeff * (Const.ImageBottomMarginForSmallState + sizeDiff))))
}()
let xTranslation = max(0, sizeDiff - coeff * sizeDiff)
imageView.transform = CGAffineTransform.identity
.scaledBy(x: scale, y: scale)
.translatedBy(x: xTranslation, y: yTranslation)
}
Step 5:
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard let height = navigationController?.navigationBar.frame.height else { return }
moveAndResizeImage(for: height)
}
Hope it's clear and helps you!
Please let me know in comments if you have any additional questions.
If anyone is still looking how to do this in SwiftUI. I made a package named NavigationBarLargeTitleItems to deal with this. It mimics the behavior you see in the AppStore and Messages-app.
Please note to be able to accomplish this behavior we need to add to the '_UINavigationBarLargeTitleView' which is a private class and therefor might get your app rejected when submitting to the App Store.
I'm also including the full relevant source code here for those who dislike links or just want to copy/paste.
Extension:
// Copyright © 2020 Mark van Wijnen
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the “Software”), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import SwiftUI
public extension View {
func navigationBarLargeTitleItems<L>(trailing: L) -> some View where L : View {
overlay(NavigationBarLargeTitleItems(trailing: trailing).frame(width: 0, height: 0))
}
}
fileprivate struct NavigationBarLargeTitleItems<L : View>: UIViewControllerRepresentable {
typealias UIViewControllerType = Wrapper
private let trailingItems: L
init(trailing: L) {
self.trailingItems = trailing
}
func makeUIViewController(context: Context) -> Wrapper {
Wrapper(representable: self)
}
func updateUIViewController(_ uiViewController: Wrapper, context: Context) {
}
class Wrapper: UIViewController {
private let representable: NavigationBarLargeTitleItems?
init(representable: NavigationBarLargeTitleItems) {
self.representable = representable
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
self.representable = nil
super.init(coder: coder)
}
override func viewWillAppear(_ animated: Bool) {
guard let representable = self.representable else { return }
guard let navigationBar = self.navigationController?.navigationBar else { return }
guard let UINavigationBarLargeTitleView = NSClassFromString("_UINavigationBarLargeTitleView") else { return }
navigationBar.subviews.forEach { subview in
if subview.isKind(of: UINavigationBarLargeTitleView.self) {
let controller = UIHostingController(rootView: representable.trailingItems)
controller.view.translatesAutoresizingMaskIntoConstraints = false
subview.addSubview(controller.view)
NSLayoutConstraint.activate([
controller.view.bottomAnchor.constraint(
equalTo: subview.bottomAnchor,
constant: -15
),
controller.view.trailingAnchor.constraint(
equalTo: subview.trailingAnchor,
constant: -view.directionalLayoutMargins.trailing
)
])
}
}
}
}
}
Usage:
import SwiftUI
import NavigationBarLargeTitleItems
struct ContentView: View {
var body: some View {
NavigationView {
List {
ForEach(1..<50) { index in
Text("Sample Row \(String(index))")
}
}
.navigationTitle("Navigation")
.navigationBarLargeTitleItems(trailing: ProfileIcon())
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct ProfileIcon: View {
var body: some View{
Button(action: {
print("Profile button was tapped")
}) {
Image(systemName: "person.circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(.red)
.frame(width: 36, height: 36)
}
.offset(x: -20, y: 5)
}
}
Preview
Thanks to #TungFam, I think I have a better solution. check it out
two points:
change button frame according to navigation bar height
// adjust topview height
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard let navBar = self.navigationController?.navigationBar else {
return
}
// hardcoded .. to improve
if navBar.bounds.height > 44 + 40 + 10 {
NSLayoutConstraint.deactivate(heightConstraint)
heightConstraint = [topview.heightAnchor.constraint(equalToConstant: 40)]
NSLayoutConstraint.activate(heightConstraint)
} else {
NSLayoutConstraint.deactivate(heightConstraint)
var height = navBar.bounds.height - 44 - 10
if height < 0 {
height = 0
}
heightConstraint = [topview.heightAnchor.constraint(equalToConstant: height)]
NSLayoutConstraint.activate(heightConstraint)
}
}
change button alpha according to pop/push progress
#objc func onGesture(sender: UIGestureRecognizer) {
switch sender.state {
case .began, .changed:
if let ct = navigationController?.transitionCoordinator {
topview.alpha = ct.percentComplete
}
case .cancelled, .ended:
return
case .possible, .failed:
break
}
}
Nice answer about adding it as a subview. I would add the fact that you could use pure auto layout only without the need of CGAffineTransform and all those calculations. If you add vertical constraints as well it will automatically scale. If you still need to use calculations you can use navigationController?.navigationBar.publisher(for: \.frame) publisher instead of doing it inside scroll view. That way you'll be able to do it more globally rather than being dependent on the scroll view.
This is how I did it for example (I needed to do it on leading and have large title hidden but you can change those constraints to add it wherever you'd like):
Add imageView as a property as I also need to hide it in some cases. (e.g., when opening other screen)
private lazy var imageView: UIImageView = {
let imageView = UIImageView()
imageView.kf.setImage(with: URL(string: "https://img.buzzfeed.com/buzzfeed-static/static/2021-07/21/15/campaign_images/b4661163b3f8/24-times-michael-scott-from-the-office-made-us-bu-2-7356-1626879661-2_dblbig.jpg?resize=1200:*")!)
imageView.cornerRadiusStyle = .heightFraction(1/2) // This is an extension in the codebase I'm working on but you can set the corner radius normally as you would. Inside layoutSubviews most probably.
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFill
imageView.isUserInteractionEnabled = true
return imageView
}()
Setup custom image (Make sure you call this AFTER navigationController is set and not nil)
func setupCustomImage() {
// Adding imageView inside stackView just for convenience of hiding it later.
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(imageView)
NSLayoutConstraint.activate([
imageView.heightAnchor.constraint(lessThanOrEqualToConstant: 52), // In my case I needed max image size to be 52. You can change that.
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor) // I needed aspect ratio to be 1:1. You can change that also by adding multiplier.
])
guard let navigationBar = navigationController?.navigationBar else { return }
navigationBar.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: navigationBar.leadingAnchor, constant: 16), // For leading padding
stackView.centerYAnchor.constraint(equalTo: navigationBar.centerYAnchor),
// You can play around with those constants as well to provide minimum size of the image needed.
navigationBar.bottomAnchor.constraint(greaterThanOrEqualTo: stackView.bottomAnchor, constant: 7),
stackView.topAnchor.constraint(greaterThanOrEqualTo: navigationBar.topAnchor, constant: 7)
])
}
It will automatically do all the scaling and stuff.
You could create the UIBarButtonItem using a custom view. This custom view will be a UIView with the actual UIButton (as a subview) placed x pixels from the top (x=the number of pixels you want to move it down).