Refactor and redesign Location Sharing Screen (#5155)

* refactored the static location screen to the location sharing screen

# Conflicts:
#	UnitTests/Sources/LocationSharingScreenViewModelTests.swift

# Conflicts:
#	ElementX.xcodeproj/project.pbxproj

* implemented a custom pin with an overlayable view

* implemented the render of the user when the location is sender instead of the pin type

* removed description and body they are not used at all.

* reimplemented single button for sharing this or user location + implemented an experimental way to update annotations

* removed unnecessary @Suite description

* implemented a way to display the alert on top of the sheet and added a spinner to the center user location button

* fixed alerts strings

* fixed a failing test

* improved preview tests
This commit is contained in:
Mauro
2026-03-06 12:51:16 +01:00
committed by GitHub
parent 0c79b5404d
commit bbbb2e9dee
85 changed files with 917 additions and 626 deletions
+38 -57
View File
@@ -84,55 +84,38 @@ enum UserAvatarSizeOnScreen {
case sendInviteConfirmation
case sessionVerification
case threadSummary
case map
var value: CGFloat {
switch self {
case .chats, .spaces:
return 32
case .timeline:
return 32
case .readReceipt:
return 16
case .readReceiptSheet:
return 32
16
case .spaceHeader:
return 20
case .completionSuggestions:
return 32
case .blockedUsers:
return 32
case .settings:
return 52
case .roomDetails:
return 44
case .roomMembersList:
return 32
case .roomChangeRoles:
return 56
case .startChat:
return 36
case .memberDetails:
return 96
case .inviteUsers:
return 52
case .editUserDetails:
return 96
case .dmDetails:
return 75
case .knockingUsersBannerStack:
return 28
case .knockingUserBanner:
return 32
case .knockingUserList:
return 52
case .mediaPreviewDetails:
return 32
case .sendInviteConfirmation:
return 64
case .sessionVerification:
return 52
20
case .threadSummary:
return 24
24
case .knockingUsersBannerStack:
28
case .chats, .spaces, .map,
.timeline, .readReceiptSheet, .completionSuggestions,
.blockedUsers, .roomMembersList, .knockingUserBanner,
.mediaPreviewDetails:
32
case .startChat:
36
case .roomDetails:
44
case .inviteUsers, .knockingUserList, .sessionVerification,
.settings:
52
case .roomChangeRoles:
56
case .sendInviteConfirmation:
64
case .dmDetails:
75
case .memberDetails, .editUserDetails:
96
}
}
}
@@ -160,24 +143,22 @@ enum RoomAvatarSizeOnScreen {
var value: CGFloat {
switch self {
case .chats, .spaces, .spaceSettings:
return 52
case .notificationSettings:
30
case .timeline, .leaveSpace, .roomDirectorySearch,
.completionSuggestions, .authorizedSpaces, .createRoomSelectSpace,
.spaceFilters:
return 32
case .notificationSettings:
return 30
case .messageForwarding, .globalSearch, .roomSelection, .spaceAddRooms:
return 36
case .spaceAddRoomsSelected:
return 52
32
case .messageForwarding, .globalSearch, .roomSelection,
.spaceAddRooms:
36
case .chats, .spaces, .spaceSettings,
.spaceAddRoomsSelected:
52
case .joinRoom, .spaceHeader:
64
case .details:
return 96
case .joinRoom:
return 64
case .spaceHeader:
return 64
96
}
}
}
@@ -11,23 +11,32 @@ import MapLibre
import SwiftUI
final class LocationAnnotation: NSObject, MLNAnnotation {
let coordinate: CLLocationCoordinate2D
let id: String
var coordinate: CLLocationCoordinate2D
let anchorPoint: CGPoint
let view: AnyView
var view: AnyView
// MARK: - Setup
init(coordinate: CLLocationCoordinate2D,
init(id: String = UUID().uuidString,
coordinate: CLLocationCoordinate2D,
anchorPoint: CGPoint = .init(x: 0.5, y: 0.5),
@ViewBuilder label: () -> some View) {
self.id = id
self.coordinate = coordinate
self.anchorPoint = anchorPoint
view = AnyView(label())
super.init()
}
func updateView(@ViewBuilder label: () -> some View) {
view = AnyView(label())
}
}
final class LocationAnnotationView: MLNUserLocationAnnotationView {
private var hostingController: UIHostingController<AnyView>?
// MARK: - Setup
override init(annotation: MLNAnnotation?, reuseIdentifier: String?) {
@@ -37,12 +46,21 @@ final class LocationAnnotationView: MLNUserLocationAnnotationView {
convenience init(annotation: LocationAnnotation) {
self.init(annotation: annotation, reuseIdentifier: "\(Self.self)")
let view: UIView = UIHostingController(rootView: annotation.view).view
let hostingController = UIHostingController(rootView: annotation.view)
self.hostingController = hostingController
let view: UIView = hostingController.view
view.backgroundColor = .clear
view.anchorPoint = annotation.anchorPoint
addSubview(view)
view.bounds.size = view.intrinsicContentSize
}
func updateContent(with view: AnyView) {
hostingController?.rootView = view
if let hostedView = hostingController?.view {
hostedView.bounds.size = hostedView.intrinsicContentSize
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
@@ -20,10 +20,10 @@ struct MapLibreMapView: UIViewRepresentable {
/// The initial map center
let mapCenter: CLLocationCoordinate2D
/// Map annotations
let annotations: [LocationAnnotation]
/// Map annotations keyed by a stable identifier (e.g. sender userID for user pins, UUID string for generic pins)
let annotations: [String: LocationAnnotation]
init(zoomLevel: Double, initialZoomLevel: Double, mapCenter: CLLocationCoordinate2D, annotations: [LocationAnnotation] = []) {
init(zoomLevel: Double, initialZoomLevel: Double, mapCenter: CLLocationCoordinate2D, annotations: [String: LocationAnnotation] = [:]) {
self.zoomLevel = zoomLevel
self.initialZoomLevel = initialZoomLevel
self.mapCenter = mapCenter
@@ -45,6 +45,7 @@ struct MapLibreMapView: UIViewRepresentable {
@Binding var error: MapLibreError?
/// Coordinate of the center of the map
@Binding var mapCenterCoordinate: CLLocationCoordinate2D?
@Binding var hasLoadedUserLocation: Bool
@Binding var isLocationAuthorized: Bool?
/// The radius of uncertainty for the location, measured in meters.
@Binding var geolocationUncertainty: CLLocationAccuracy?
@@ -70,6 +71,11 @@ struct MapLibreMapView: UIViewRepresentable {
mapView.styleURL = dynamicMapURL
}
// Update existing annotation views with fresh SwiftUI content.
// This handles the case where the annotation's view data changes after
// the annotation was initially placed (e.g. user avatar loads asynchronously).
updateAnnotations(in: mapView)
showUserLocation(in: mapView)
}
@@ -80,17 +86,52 @@ struct MapLibreMapView: UIViewRepresentable {
// MARK: - Private
private func setupMap(mapView: MLNMapView, with options: Options) {
mapView.addAnnotations(options.annotations)
mapView.addAnnotations(Array(options.annotations.values))
mapView.zoomLevel = options.annotations.isEmpty ? options.initialZoomLevel : options.zoomLevel
mapView.centerCoordinate = options.mapCenter
}
private func updateAnnotations(in mapView: MLNMapView) {
let existingByID = Dictionary(uniqueKeysWithValues:
(mapView.annotations ?? []).compactMap { $0 as? LocationAnnotation }.map { ($0.id, $0) })
let existingIDs = Set(existingByID.keys)
let updatedIDs = Set(options.annotations.keys)
// Remove annotations that are no longer present
let removedIDs = existingIDs.subtracting(updatedIDs)
if !removedIDs.isEmpty {
let toRemove = removedIDs.compactMap { existingByID[$0] }
mapView.removeAnnotations(toRemove)
}
// Add new annotations
let addedIDs = updatedIDs.subtracting(existingIDs)
if !addedIDs.isEmpty {
let toAdd = addedIDs.compactMap { options.annotations[$0] }
mapView.addAnnotations(toAdd)
}
// Update existing annotations that are still present
let keptIDs = existingIDs.intersection(updatedIDs)
for id in keptIDs {
guard let existingAnnotation = existingByID[id],
let updatedAnnotation = options.annotations[id] else {
continue
}
existingAnnotation.coordinate = updatedAnnotation.coordinate
if let annotationView = mapView.view(for: existingAnnotation) as? LocationAnnotationView {
annotationView.updateContent(with: updatedAnnotation.view)
}
}
}
private func makeMapView() -> MLNMapView {
let mapView = MLNMapView(frame: .zero, styleURL: mapURLBuilder.interactiveMapURL(for: colorScheme == .dark ? .dark : .light))
mapView.logoViewPosition = .topLeft
mapView.attributionButtonPosition = .topLeft
mapView.attributionButtonMargins = .init(x: mapView.logoView.frame.maxX + 8, y: mapView.logoView.center.y / 2)
mapView.tintColor = .black
mapView.tintColor = .compound.iconAccentPrimary
mapView.allowsRotating = false
mapView.allowsTilting = false
return mapView
@@ -149,7 +190,8 @@ extension MapLibreMapView {
func mapView(_ mapView: MLNMapView, didUpdate userLocation: MLNUserLocation?) {
guard let userLocation else { return }
mapLibreView.hasLoadedUserLocation = true
if previousUserLocation == nil, mapLibreView.options.annotations.isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
mapView.setCenter(userLocation.coordinate, zoomLevel: self.mapLibreView.options.zoomLevel, animated: true)
@@ -18,12 +18,12 @@ final class ShareToMapsAppActivity: UIActivity {
private let type: MapsAppType
private let location: CLLocationCoordinate2D
private let locationDescription: String?
private let senderName: String?
init(type: MapsAppType, location: CLLocationCoordinate2D, locationDescription: String?) {
init(type: MapsAppType, location: CLLocationCoordinate2D, senderName: String?) {
self.type = type
self.location = location
self.locationDescription = locationDescription
self.senderName = senderName
super.init()
}
@@ -44,20 +44,20 @@ final class ShareToMapsAppActivity: UIActivity {
}
override func prepare(withActivityItems activityItems: [Any]) {
UIApplication.shared.open(type.activityURL(for: location, locationDescription: locationDescription), options: [:]) { [weak self] result in
UIApplication.shared.open(type.activityURL(for: location, senderName: senderName), options: [:]) { [weak self] result in
self?.activityDidFinish(result)
}
}
}
extension ShareToMapsAppActivity.MapsAppType {
func activityURL(for location: CLLocationCoordinate2D, locationDescription: String?) -> URL {
func activityURL(for location: CLLocationCoordinate2D, senderName: String?) -> URL {
switch self {
case .apple:
var url: URL = "https://maps.apple.com/"
url.append(queryItems: [
.init(name: "ll", value: "\(location.latitude),\(location.longitude)"),
.init(name: "q", value: locationDescription ?? "Pin")
.init(name: "q", value: senderName ?? "Pin") // We need to provide a value or no marker is displayed.
])
return url
case .google:
@@ -0,0 +1,30 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
struct RowDivider: ViewModifier {
@Environment(\.pixelLength) var pixelLength: CGFloat
let alignment: Alignment
let horizontalInsets: CGFloat
func body(content: Content) -> some View {
content
.overlay(alignment: alignment) {
Rectangle()
.fill(Color.compound.borderDisabled)
.frame(height: pixelLength)
.padding(.trailing, -horizontalInsets)
}
}
}
extension View {
func rowDivider(alignment: Alignment = .bottom, horizontalInsets: CGFloat) -> some View {
modifier(RowDivider(alignment: alignment, horizontalInsets: horizontalInsets))
}
}
@@ -10,28 +10,84 @@ import Compound
import SwiftUI
struct LocationMarkerView: View {
private let pinColor: Color = .compound.iconOnSolidPrimary
private let pinInsets = EdgeInsets(top: 13, leading: 12, bottom: 15, trailing: 12)
var userProfile: UserProfileProxy?
var fillColor = Color.compound.bgCanvasDefault
var strokeColor = Color.compound.iconSecondaryAlpha
var dotColor = Color.compound.iconPrimary
@ScaledMetric var size: CGFloat = 42
var mediaProvider: MediaProviderProtocol?
private let circleCenter = CGPoint(x: 21, y: 21) // in SVG space
private let circleRadius: CGFloat = 6 // in SVG space
var body: some View {
CompoundIcon(\.locationPinSolid)
.dynamicTypeSize(.large)
.foregroundStyle(pinColor)
.padding(pinInsets)
.background {
backgroundShape
.shadow(color: .black.opacity(0.2), radius: 4.1129, x: 0, y: 4.93548)
// Generated from the SVG
Canvas { context, canvasSize in
let scaleX = canvasSize.width / 42
let scaleY = canvasSize.height / 50
let pinPath = Path { p in
p.move(to: CGPoint(x: 21 * scaleX, y: 49 * scaleY))
p.addCurve(to: CGPoint(x: 17.6875 * scaleX, y: 47.7581 * scaleY),
control1: CGPoint(x: 19.25 * scaleX, y: 49 * scaleY),
control2: CGPoint(x: 18.1458 * scaleX, y: 48.4825 * scaleY))
p.addCurve(to: CGPoint(x: 5.28125 * scaleX, y: 33.6313 * scaleY),
control1: CGPoint(x: 12.5833 * scaleX, y: 42.8525 * scaleY),
control2: CGPoint(x: 8.41667 * scaleX, y: 38.1332 * scaleY))
p.addCurve(to: CGPoint(x: 1 * scaleX, y: 21.3674 * scaleY),
control1: CGPoint(x: 2.82292 * scaleX, y: 29.2846 * scaleY),
control2: CGPoint(x: 1 * scaleX, y: 25.1863 * scaleY))
p.addCurve(to: CGPoint(x: 21 * scaleX, y: 1 * scaleY),
control1: CGPoint(x: 1 * scaleX, y: 10.2109 * scaleY),
control2: CGPoint(x: 9.5 * scaleX, y: 1 * scaleY))
p.addCurve(to: CGPoint(x: 41 * scaleX, y: 21.3674 * scaleY),
control1: CGPoint(x: 32.5 * scaleX, y: 1 * scaleY),
control2: CGPoint(x: 41 * scaleX, y: 10.2109 * scaleY))
p.addCurve(to: CGPoint(x: 36.7188 * scaleX, y: 33.6313 * scaleY),
control1: CGPoint(x: 41 * scaleX, y: 25.1863 * scaleY),
control2: CGPoint(x: 39.1771 * scaleX, y: 29.2846 * scaleY))
p.addCurve(to: CGPoint(x: 24.3125 * scaleX, y: 47.7581 * scaleY),
control1: CGPoint(x: 33.5833 * scaleX, y: 38.1332 * scaleY),
control2: CGPoint(x: 29.4167 * scaleX, y: 42.8525 * scaleY))
p.addCurve(to: CGPoint(x: 21 * scaleX, y: 49 * scaleY),
control1: CGPoint(x: 23.3333 * scaleX, y: 48.4825 * scaleY),
control2: CGPoint(x: 21.5833 * scaleX, y: 49 * scaleY))
p.closeSubpath()
}
.alignmentGuide(VerticalAlignment.center) { dimensions in
dimensions[.bottom]
context.stroke(pinPath, with: .color(strokeColor), lineWidth: 2 * scaleX)
context.fill(pinPath, with: .color(fillColor))
// Dot
let dotPath = Path(ellipseIn: CGRect(x: (circleCenter.x - circleRadius) * scaleX,
y: (circleCenter.y - circleRadius) * scaleY,
width: circleRadius * 2 * scaleX,
height: circleRadius * 2 * scaleY))
context.fill(dotPath, with: .color(dotColor))
// Draw resolved symbol centered on the circle
if userProfile != nil, let symbol = context.resolveSymbol(id: 0) {
let center = CGPoint(x: circleCenter.x * scaleX,
y: circleCenter.y * scaleY)
context.draw(symbol, at: center, anchor: .center)
}
}
var backgroundShape: some View {
Image(asset: Asset.Images.locationMarkerShape)
.resizable()
.aspectRatio(contentMode: .fill)
.foregroundStyle(.compound.iconPrimary)
} symbols: {
if let userProfile {
LoadableAvatarImage(url: userProfile.avatarURL,
name: userProfile.displayName,
contentID: userProfile.userID,
avatarSize: .user(on: .map),
mediaProvider: mediaProvider)
.overlay {
Circle().inset(by: 0.5).stroke(strokeColor)
}
.tag(0)
}
}
.frame(width: size, height: size * 50 / 42)
.alignmentGuide(VerticalAlignment.center) { dimensions in
dimensions[.bottom]
}
}
}
@@ -39,9 +95,19 @@ struct LocationMarkerView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(spacing: 30) {
LocationMarkerView()
LocationMarkerView()
.colorScheme(.dark)
LocationMarkerView(userProfile: UserProfileProxy.mockDan,
mediaProvider: MediaProviderMock(configuration: .init()))
LocationMarkerView(userProfile: UserProfileProxy.mockDan,
mediaProvider: MediaProviderMock(configuration: .init()))
.colorScheme(.dark)
}
.padding(16)
.background(Color(red: 0.9, green: 0.85, blue: 0.8))
.previewLayout(.sizeThatFits)
}
}
@@ -14,6 +14,7 @@ struct ToolbarButton: View {
static let cancel = Role.cancel(title: L10n.actionCancel)
static let done = Role.confirm(title: L10n.actionDone)
static let save = Role.confirm(title: L10n.actionSave)
static let close = Role.cancel(title: L10n.actionClose)
case cancel(title: String)
case confirm(title: String)
@@ -83,6 +83,7 @@ enum TestablePreviewsDictionary {
"LoadableImage_Previews" : LoadableImage_Previews.self,
"LocationMarkerView_Previews" : LocationMarkerView_Previews.self,
"LocationRoomTimelineView_Previews" : LocationRoomTimelineView_Previews.self,
"LocationSharingScreen_Previews" : LocationSharingScreen_Previews.self,
"LoginScreen_Previews" : LoginScreen_Previews.self,
"LongPressWithFeedback_Previews" : LongPressWithFeedback_Previews.self,
"ManageAuthorizedSpacesScreen_Previews" : ManageAuthorizedSpacesScreen_Previews.self,
@@ -181,7 +182,6 @@ enum TestablePreviewsDictionary {
"StackedAvatarsView_Previews" : StackedAvatarsView_Previews.self,
"StartChatScreen_Previews" : StartChatScreen_Previews.self,
"StateRoomTimelineView_Previews" : StateRoomTimelineView_Previews.self,
"StaticLocationScreenViewer_Previews" : StaticLocationScreenViewer_Previews.self,
"StickerRoomTimelineView_Previews" : StickerRoomTimelineView_Previews.self,
"SwipeRightAction_Previews" : SwipeRightAction_Previews.self,
"SwipeToReplyView_Previews" : SwipeToReplyView_Previews.self,