diff --git a/yobble/Views/Tab/Settings/EditProfileView.swift b/yobble/Views/Tab/Settings/EditProfileView.swift index f13cd2d..4d57b80 100644 --- a/yobble/Views/Tab/Settings/EditProfileView.swift +++ b/yobble/Views/Tab/Settings/EditProfileView.swift @@ -288,14 +288,29 @@ struct EditProfileView: View { private func presentAvatarViewer() { if let image = avatarImage { - avatarViewerState = AvatarViewerState(source: .local(image)) + avatarViewerState = AvatarViewerState( + source: .local(image), + intrinsicSize: image.size + ) return } guard let profile, let fileId = profile.avatars?.current?.fileId, let url = avatarUrl(for: profile, fileId: fileId) else { return } - avatarViewerState = AvatarViewerState(source: .remote(url: url, fileId: fileId, userId: profile.userId.uuidString)) + let intrinsicSize: CGSize? + if let width = profile.avatars?.current?.width, + let height = profile.avatars?.current?.height, + width > 0, + height > 0 { + intrinsicSize = CGSize(width: CGFloat(width), height: CGFloat(height)) + } else { + intrinsicSize = nil + } + avatarViewerState = AvatarViewerState( + source: .remote(url: url, fileId: fileId, userId: profile.userId.uuidString), + intrinsicSize: intrinsicSize + ) } private func handleAvatarDownload(for state: AvatarViewerState) { @@ -389,6 +404,7 @@ struct AvatarViewerState: Identifiable { let id = UUID() let source: Source + let intrinsicSize: CGSize? } enum AvatarViewerError: LocalizedError { @@ -414,6 +430,8 @@ struct AvatarViewerView: View { @State private var storedPanOffset: CGSize = .zero @State private var dismissOffset: CGSize = .zero @State private var dragMode: DragMode? + @State private var containerSize: CGSize = .zero + @State private var loadedImageSize: CGSize? private enum DragMode { case vertical @@ -438,6 +456,10 @@ struct AvatarViewerView: View { Double(1 - dragProgress * 0.8) } + private var effectiveImageSize: CGSize? { + loadedImageSize ?? state.intrinsicSize + } + var body: some View { ZStack { Color.black.opacity(backgroundOpacity).ignoresSafeArea() @@ -488,27 +510,35 @@ struct AvatarViewerView: View { @ViewBuilder private var zoomableContent: some View { + GeometryReader { proxy in + let size = proxy.size + Color.clear + .onAppear { containerSize = size } + .onChange(of: size) { newValue in + containerSize = newValue + } + .overlay { + content(for: size) + } + } + } + + @ViewBuilder + private func content(for size: CGSize) -> some View { switch state.source { case .local(let image): zoomableImage(Image(uiImage: image)) - case .remote(let url, _, _): - AsyncImage(url: url) { phase in - switch phase { - case .empty: - ProgressView() - .progressViewStyle(.circular) - case .success(let image): - zoomableImage(image) - case .failure: - Image(systemName: "person.crop.circle.badge.exclam") - .resizable() - .scaledToFit() - .frame(width: 120, height: 120) - .foregroundColor(.white.opacity(0.7)) - @unknown default: - EmptyView() + .onAppear { + loadedImageSize = image.size } + case .remote(let url, let fileId, let userId): + RemoteZoomableImage(url: url, fileId: fileId, userId: userId) { uiImage in + loadedImageSize = uiImage.size } + .offset(currentOffset) + .scaleEffect(scale, anchor: .center) + .gesture(dragGesture) + .simultaneousGesture(magnificationGesture) } } @@ -518,9 +548,9 @@ struct AvatarViewerView: View { .scaledToFit() .offset(currentOffset) .scaleEffect(scale, anchor: .center) - .frame(maxWidth: .infinity, maxHeight: .infinity) .gesture(dragGesture) .simultaneousGesture(magnificationGesture) + .frame(maxWidth: .infinity, maxHeight: .infinity) } private var dragGesture: some Gesture { @@ -536,6 +566,7 @@ struct AvatarViewerView: View { width: storedPanOffset.width + adjustedTranslation.width, height: storedPanOffset.height + adjustedTranslation.height ) + panOffset = clampedOffset(panOffset) } else { if dragMode == nil { if abs(value.translation.height) > abs(value.translation.width) { @@ -560,10 +591,12 @@ struct AvatarViewerView: View { width: value.translation.width / scale, height: value.translation.height / scale ) - storedPanOffset = CGSize( + var newOffset = CGSize( width: storedPanOffset.width + adjustedTranslation.width, height: storedPanOffset.height + adjustedTranslation.height ) + newOffset = clampedOffset(newOffset) + storedPanOffset = newOffset } else { if abs(value.translation.height) > 120 { onClose() @@ -596,6 +629,62 @@ struct AvatarViewerView: View { } } } + + private func clampedOffset(_ offset: CGSize) -> CGSize { + guard scale > 1.01, + containerSize != .zero else { return offset } + + let fittedSize = fittedContentSize(in: containerSize) + let scaledWidth = fittedSize.width * scale + let scaledHeight = fittedSize.height * scale + let maxX = max(0, (scaledWidth - containerSize.width) / 2) + let maxY = max(0, (scaledHeight - containerSize.height) / 2) + + let clampedX = max(-maxX, min(offset.width, maxX)) + let clampedY = max(-maxY, min(offset.height, maxY)) + return CGSize(width: clampedX, height: clampedY) + } + + private func fittedContentSize(in container: CGSize) -> CGSize { + guard let imageSize = effectiveImageSize, + imageSize.width > 0, + imageSize.height > 0 else { + return container + } + + let widthRatio = container.width / imageSize.width + let heightRatio = container.height / imageSize.height + let ratio = min(widthRatio, heightRatio) + return CGSize(width: imageSize.width * ratio, height: imageSize.height * ratio) + } +} + +private struct RemoteZoomableImage: View { + @StateObject private var loader: ImageLoader + let onImageLoaded: (UIImage) -> Void + + init(url: URL, fileId: String, userId: String, onImageLoaded: @escaping (UIImage) -> Void) { + _loader = StateObject(wrappedValue: ImageLoader(url: url, fileId: fileId, userId: userId)) + self.onImageLoaded = onImageLoaded + } + + var body: some View { + Group { + if let image = loader.image { + Image(uiImage: image) + .resizable() + .scaledToFit() + .onAppear { + onImageLoaded(image) + } + } else { + ProgressView() + .progressViewStyle(.circular) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear(perform: loader.load) + } } struct ActivityView: UIViewControllerRepresentable {